ouroboros/docs/CREATING_SKILLS.md

82 KiB
Raw Permalink Blame History

Creating Skills for Ouroboros

This is the practical guide for writing your own skills that Ouroboros can install, review, enable, and execute. It is the single place where the manifest schema, the PluginAPI, the review checklist, the lifecycle (install → review → enable → execute), the widget render schemas, and the marketplace publishing flows are explained together.

If you are looking for the runtime architecture (how the loader imports plugins, how the tri-model review pipeline is wired, etc.), read docs/ARCHITECTURE.md. If you want to know exactly what a reviewer model is asked to check, read the "Skill Review Checklist" section of docs/CHECKLISTS.md.

What is a skill?

A skill is a small package that adds capabilities to Ouroboros: new tools the agent can call, HTTP routes the desktop app can fetch, WebSocket message handlers, and host-rendered widget UIs. Skills are reviewed before they can run. Skills dropped in from disk or marketplaces go through tri-model review and explicit owner lifecycle actions. Skills authored by the current Ouroboros agent session carry both payload-local .self_authored.json and owner-state data/state/skills/<name>/self_authored.json markers for provenance, but they still go through the same tri-model skill review before they can run.

Owner attestation (skip review for your own or verified official skill). For an external or self-authored skill, and for a hash-verified official OuroborosHub payload, the OWNER may skip the expensive LLM review via the ⚠️ Skip review action on the skill card (owner-only POST /api/owner/skills/{skill}/attest-review). The deterministic preflight + manifest-validation floor STILL runs (an invalid or unsafe payload is refused), and official-hub payloads are freshly rechecked against the live catalog before attestation is persisted. Only the tri-model LLM phase is skipped; the verdict is marked owner-attested (distinct from an LLM-clean badge) and does not confer publication readiness. Choosing Publish may still start the ordinary managed publication task, but Ouroboros must complete a fresh full skill review before any outbound GitHub effect. Native, ClawHub, and unverified OuroborosHub payloads are never attestable. The agent cannot self-attest (the marker is owner-state).

There are three skill types:

Type What it ships When to use
instruction Markdown-only SKILL.md (no code). Pure prompts / playbooks for the agent.
script One or more scripts under scripts/ plus a manifest. Heavy / batch work that runs as a subprocess.
extension A plugin.py that registers tools/routes/widgets via PluginAPI. Host-integrated capabilities, including widgets and chat-driven tools; native-risk isolated deps dispatch in short-lived child processes.

The runtime ownership of an installed skill is also tagged:

  • native: bundled with the launcher (e.g. unix_computer_use).
  • self_authored: created by Ouroboros itself in the current data plane; marked by .self_authored.json and reviewed through the standard tri-model skill-review path.
  • external: dropped into data/skills/external/ by the user.
  • clawhub: installed via the ClawHub marketplace.
  • ouroboroshub: installed via the official OuroborosHub catalog.

User-authored or manually copied skills belong under data/skills/external/<name>/. The native bucket is reserved for launcher-seeded skills that carry a .seed-origin marker. That marker, not the directory name alone, is the ownership fact: an existing payload under native/ without the marker is treated in place as user-managed external content. Ordinary top-level tasks can inspect, edit, run commands in, or delegate that payload without a migration. A marker-present launcher seed stays read/review-only, and new skills are still created under external/.

Manifest schema (SKILL.md frontmatter or skill.json)

A manifest is YAML frontmatter inside SKILL.md, OR a standalone skill.json. Both shapes parse to the same dataclass. Use whichever fits your editing workflow.

---
name: weather                       # required, alnum/underscore/dash, ≤64 chars
description: Live weather widget    # required, short summary
version: 0.2.1                      # required, free-form (semver recommended)
type: extension                     # instruction | script | extension
runtime: python3                    # script skills: python/python3/bash/node/deno/ruby/go; extension entry modules are Python plugin.py
entry: plugin.py                    # type=extension only — relative to skill dir
scripts:                            # type=script only
  - name: fetch.py                  # name resolves under scripts/ unless slashes/extensions
    description: Fetch and render
permissions: [net, tool, route, widget, read_settings, companion_process, supervised_task] # see "Permissions"
conflicts: [legacy-weather]            # optional incompatible installed skill names
env_from_settings: [OPENROUTER_API_KEY]                  # core keys require an owner grant
when_to_use: User asks for the weather forecast.
model_experience:                   # optional prose (CPL-7); a bare string is also accepted
  what_model_sees: One weather tool joins the tool list; results come back as compact JSON.
  token_effect: Small fixed schema cost per round while enabled.
timeout_sec: 60                     # default 60, hard cap 300
companion_processes:                # optional; PLURAL, and needs the companion_process permission
  - name: demo_worker               # see "Declaring a companion process"
    command: [python3, scripts/worker.py]
    runtime: python3
scheduled_tasks:                    # optional reviewed cron jobs
  - name: refresh-cache
    cron: "0 * * * *"                # 5-field cron, host-local timezone by default
    timezone: Europe/Moscow          # optional IANA timezone override
    description: Refresh shared weather cache hourly.
ui_tab:                             # extension widgets (optional)
  tab_id: live
  title: Weather
  icon: "⛅"                        # one glyph (emoji / symbol); a name like `cloud` is not rendered
  render:
    kind: declarative
    start: auto                     # launch policy; module/iframe may say manual | retain (see "Launch policy")
    schema_version: 1
    components:
      - type: form
        route: search
        method: POST
        target: result
        fields:
          - name: city
            label: City
            type: text
        submit_label: Refresh
      - type: kv
        target: result
        fields:
          - label: Temperature
            path: temp_c
---

# Weather

Markdown body explaining the skill to the user / reviewer / agent.
Everything below the closing `---` becomes `manifest.body`.

runtime is optional for type: instruction (instruction skills never execute), and required for script / extension. Allowed values are python, python3, bash, node, plus the v5.7.0 additions deno, ruby, go. The actual binary is resolved through shutil.which at exec time, so the operator's host must ship the runtime; otherwise skill_exec fails closed with a clear error.

Go scripts are compiled into a private temporary executable and then run with the caller's arguments unchanged, including arguments ending in .go. The compiler and program share the invocation timeout; runtime_phase distinguishes a compilation failure from the program's own exit status. Cleanup uses the same tracked-process path as the other script runtimes.

Deno receives run --no-prompt and permissions before the script operand. Ordinary reads remain available; fs permits writes outside the existing skill state directory, net permits network calls unless the task disables network, and subprocess permits child processes. Environment access names only the keys actually forwarded after grants. A task with network disabled also requires cached imports. These are the existing reviewed script effects, not a new OS sandbox: in particular, an allowed child process has ordinary host privileges.

conflicts is an optional list of canonical skill names (letters, numbers, dash, underscore, or dot; at most 32 entries). If either enabled skill names the other, both readiness and extension loading fail closed with a typed conflict until the owner disables one. Ouroboros never resolves a conflict by automatically disabling, deleting, or moving either payload.

model_experience is an optional prose section describing the skill from the model's point of view: what_model_sees (what the skill adds to the model's context — tools, sections, attachments) and token_effect (roughly what that costs and when it is loaded). A bare string is shorthand for what_model_sees. The prose travels verbatim (bounded) to the model-visible surfaces — the list_skills JSON and the "Installed Skills" context section — so write it for the model, not for the human reviewer. Unknown keys or non-string values are refused at parse time.

Manifest refusals teach: every SkillManifestError carries the problem plus, where the parser knows the repair, a fix_hint rendered into the message (... — fix: ...), so a refused registration tells you how to fix the manifest instead of only what was wrong.

Lifecycle: install → review → enable → execute

flowchart LR
    install[install] --> review[skill_review]
    review --> triad[reviewer-slot skill review]
    triad -- PASS --> deps
    deps --> enable[owner toggles enabled=true]
    enable --> execute[skill_exec / dispatch]
    review -- FAIL/PREFLIGHT --> repair[Repair → re-review]
    repair --> review
  • Install lands the payload under the appropriate bucket (data/skills/<bucket>/<name>/). Marketplace installs also write a provenance sidecar (.clawhub.json / .ouroboroshub.json).
  • Review runs three reviewer models in parallel against the Skill Review Checklist (see docs/CHECKLISTS.md). The review pack hashes every runtime-reachable file in the skill directory; any later edit invalidates the executable verdict. .self_authored.json is provenance only; self-authored skills use the same tri-model review, grant, enable, and extension reload flow as other executable skills.
  • Isolated deps (pip / npm / uv / node) install into data/skills/<bucket>/<name>/.ouroboros_env/. Status is recorded in data/state/skills/<name>/deps.json.
  • Enable flips enabled.json after a fresh executable review + grants + deps. The Skills UI surfaces a toggle; agents can also call toggle_skill. A self-authored skill's first enablement can follow OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS; otherwise enabling requires the owner toggle or an agent acting on the owner's expressed instruction.
  • Execute: skill_exec runs type: script skills as subprocess. type: extension skills without isolated deps normally run through the in-process loader; extensions with reviewed isolated deps are cataloged and dispatched in short-lived child processes so a dependency crash or Rust/C abort cannot crash the server.

Data layout for stateful skills

Stateful skills should keep every user-visible job/session isolated under a per-job directory. For extensions, prefer:

job_dir = api.skill_job_dir(job_id)
assets_dir = job_dir / "assets"
output_dir = job_dir / "output"
tmp_dir = job_dir / "tmp"

This creates data/state/skills/<skill>/jobs/<sanitized_id>-<hash>/{assets,output,tmp}. Use it for generated images, audio, video frames, intermediate artifacts, and per-request temp files. Keep shared learned data such as prompt lessons or small caches at the skill state root only when it is intentionally shared across jobs.

Avoid flat content-keyed filenames such as assets/keyframe_0.png, concat.txt, or _vframe_0.png directly under state_dir; a later or parallel job can overwrite them. Retry outputs should include an attempt number or short random suffix, and temporary verification files should live under tmp/ and be cleaned when the job finishes.

Declaring dependencies

Skills may declare auto-installable dependencies in frontmatter:

dependencies: [ddgs]

or with explicit install specs:

install:
  - kind: pip
    package: ddgs

Bare dependencies entries are treated as Python packages. pip, pipx, uv, npm, and node specs are installed only after a fresh executable review and only under the skill's .ouroboros_env directory. Global package-manager specs remain manual setup guidance. Exact resources and explicit build actions use the same isolated install owner:

install_specs:
  - kind: download
    url: https://example.org/application/disk.img
    # Replace both with the publisher's exact artifact facts.
    sha256: "0000000000000000000000000000000000000000000000000000000000000000"
    size_bytes: 9437184
    version: application-data-1
    target: resources/disk.img
    platforms: [linux, darwin, win32]

target, step cwd, and outputs are relative to .ouroboros_env and must remain inside it. Platform names are sys.platform, optionally followed by - and the lowercase platform.machine() value (for example, linux-x86_64 or darwin-arm64); omitted platforms applies everywhere. An inapplicable entry is recorded as skipped. Resources are verified by size and digest before landing; no package-size cap is borrowed for this file path. The content-addressed resource and package caches live under state/skills/<name>/dependency_cache/, outside the replaceable payload/env. Replacing the environment or retrying therefore reuses verified downloaded bytes; a different digest selects a different cache entry.

An entry may also declare steps: [{argv: [...], cwd: "."}], outputs: ["bin/helper"], and check: {argv: ["helper", "--version"]}. Arguments stay literal; no shell interpolation is performed. Steps require outputs and a concrete successful check; declared bins must resolve after installation. Build tools use the existing process tracking, timeout and cancellation path. The normal installer invokes new downloads/build declarations only against their fresh executable review and hash-covered specs, rechecking the pinned payload before each build/check process.

Python allow_source_build: true and npm allow_install_scripts: true opt in per entry, each with a declared check. Without those flags pip remains wheel-only and npm keeps --ignore-scripts. An already installed npm package can be rebuilt explicitly on retry without rebuilding unrelated packages. deps.json records resource digests, actual resolved package metadata, output hashes and build diagnostics. installed records delivery; executable_ready is unknown without a declared check and true only after that check succeeds. A failed check stays failed, even if the package manager returned zero. Manual dependencies do not acquire a new universal probe gate.

For type: extension, any reviewed isolated dependency env is kept out of server.py: plugin.py cataloging and tool/route/WS handlers run in a short-lived child process with the skill's payload and isolated env. Child crashes surface as tool errors, HTTP 502 responses, or WebSocket log messages; the server and WebSocket stay alive. Opaque native files shipped directly in the payload are still review-sensitive; if such a marker exists, the runtime also uses child dispatch defensively, but this guide does not make native payload binaries broadly acceptable. Out-of-process extensions have near full PluginAPI parity. They proxy tools, HTTP routes, WS handlers, UI tabs, and settings sections; on_unload runs at child teardown; send_ws_message relays to the browser through the Host Service POST /ui/ws-message bridge; and register_companion_process is cataloged so the host spawns and supervises a long-lived companion for background work. Only two capabilities have no meaning in a short-lived per-call child and stay unavailable there: subscribe_event and register_supervised_task (an in-process asyncio task). For long-running work, host-event subscription, or live progress that must outlast a single call, declare a companion_process — a host-supervised subprocess that already receives HOST_SERVICE_URL/HOST_SERVICE_TOKEN and can call POST /ui/ws-message and connect to WS /events. A skill can read get_runtime_info()["execution_mode"] and ["capabilities"] to adapt instead of calling an unavailable capability and aborting registration.

Out-of-process caveats: (1) register() and on_unload run for each per-call child (every tool/route/WS dispatch and catalog), so on_unload fires per call, not once per disable — keep it cheap and idempotent and put durable/once-per-session teardown in a companion_process shutdown. (2) send_ws_message relays through the loopback Host Service and is best-effort: the relay lane holds a 60-message burst reserve per skill that refills one message per second, the excess is refused (a 429 with retry_after_sec; the host records each refused burst once with its dropped count), so a progress-heavy job should throttle updates or rely on poll-based status. Successful children also return aggregate transport/HTTP refusal counts through their normal result/process diagnostics; send_ws_message still returns None, and acceptance does not prove browser delivery. (3) A companion_process is spawned and supervised by the host server process: enabling a companion skill from the agent's toggle_skill tool or via post-review auto-enable records it in the worker process and writes a durable per-request marker under state/extension_reconcile/ (filename includes a safe skill prefix plus request id). The server pickup task consumes that marker and starts/stops registered companions shortly after; the UI/HTTP enable still starts them immediately because it runs inside the server. Design companions to tolerate this small asynchronous handoff.

Scheduled skill tasks and daemons

Skills may declare reviewed cron jobs in scheduled_tasks. A scheduled task is not a hidden daemon: it is a reviewed schedule entry that the core supervisor turns into ordinary queued work using the same task queue, logs, timeout, and review-visible provenance as other work. The first supported syntax is standard 5-field cron (minute hour day month weekday), with host-local timezone by default and optional per-task IANA timezone override. If Ouroboros was offline when a cron became due, the scheduler performs one catch-up run rather than replaying every missed occurrence.

In v1, a skill scheduled task is an agent-visible reminder to run the reviewed skill task by name; the agent still chooses the correct skill_exec or extension surface at execution time. Deterministic script/route targets can be added as a future manifest extension once the schedule lifecycle has stabilized.

Long-running processes are still represented by extension companion processes, not by start_service. start_service is task-scoped and is stopped when the owning task ends. Companions require reviewed manifest declarations and the companion_process permission; scheduled tasks require review-visible manifest metadata and the supervised_task permission.

Declaring a companion process

The descriptor lives in the manifest; plugin.py only names it. The key is companion_processes — plural — and register_companion_process() accepts only that name (positionally or by keyword):

permissions: [companion_process]
companion_processes:
  - name: demo_worker                     # alnum/underscore only, max 24 chars
    command: [python3, scripts/worker.py] # relative POSIX-style path in the skill
    runtime: python3
    restart_policy: on_failure            # default; the only policy that restarts
    max_restarts: 5                       # default; the bound within a 300s window
def register(api):
    api.register_companion_process("demo_worker")   # only the declared name

Two different layers check this, and they fail at different moments. The parser rejects a malformed descriptor when the skill is read:

Rule Error when broken
name is present and non-empty each 'companion_processes' item must include name
command is present and non-empty each 'companion_processes' item must include a non-empty command list
runtime is declared each 'companion_processes' item must include runtime
A python/python3 runtime names a script — a bare [python3] does not python companion command must name a reviewed script
No argument is -c, -m, -e, --eval or eval. [python3, -m, mypackage, boot] is caught here rather than by the rule above, even when the package is a declared dependency — and so is a script of your own passed a flag that happens to be named -c companion inline/eval commands are not allowed
Path arguments use relative POSIX-style paths (no leading / or .. segment) companion command arguments must stay inside the reviewed skill tree

The path check is lexical and does not verify that the target file exists. Keep the script in the skill payload and use / separators so the descriptor has the same meaning on every supported platform.

Registration then checks the manifest permission, the name constraints that the parser does not enforce, and whether the name is declared. A manifest that has passed parsing but is wrong in these ways fails from plugin.py:

Rule Error when broken
The manifest includes the companion_process permission skill 'x' cannot 'companion_process' — manifest permissions=[...] when it is missing
name is at most 24 characters tool name must be <= 24 characters: '...'
name contains only alnum/underscore — demo-worker parses in the manifest but is not a registrable name tool name must be alnum/underscore only: 'demo-worker'
The name passed to register_companion_process() is declared in the manifest companion 'x' is not declared in manifest.companion_processes

If you read that last error, check the key for a missing s first. An unrecognised frontmatter key is kept as an extra rather than refused — install_specs and the forward-compatible presence: block are both read that way — so companion_process: in the singular parses, declares no companions, and currently produces no warning from the parser, validate(), or skill_preflight. The error you eventually meet names the plural key and cannot tell you that the singular one was sitting in the manifest all along.

There is no on_enable trigger and no once flag. After a skill is enabled, the host starts a companion whose name register() has registered; for an out-of-process extension, this handoff can be asynchronous. The companion is supervised from there; restart_policy: on_failure restarts it only on a non-zero exit, so a one-shot script that exits 0 runs once, and max_restarts (default 5, counted within a 300-second window) bounds the loop before companion_restart_exhausted is recorded. For work that must happen before the first tool call, remember that an out-of-process extension already runs register() in every per-call child — see the out-of-process caveats above — so cheap idempotent setup belongs there, not in a companion.

env and ports may also be declared on the descriptor. Neither is passed through as written: the host drops HOST_SERVICE_TOKEN, HOST_SERVICE_URL and any forbidden settings key out of env (and sets the first two itself), and it keeps only all-digit entries from ports, so a negative or non-numeric one is dropped in silence.

The skill_preflight tool

When you are writing or repairing a skill, skill_preflight runs cheap, offline syntax validators on the payload — in-process Python compile() for .py files (no __pycache__ writes), node --check for .js/.mjs/.cjs (a declared module-widget entry is instead parsed as a classic script, the grammar the widget frame runs it in, so top-level import/export in the entry fails preflight), bash -n for .sh/.bash, plus a manifest parse, explicit entry/script existence checks (including a module widget's render.entry existence and containment), and static widget render-schema validation. It validates manifest ui_tab.render plus actual register_ui_tab and register_settings_section calls in plugin.py through the same runtime validator as extension_loader. The static resolver accepts an inline literal, a module-level literal assignment, or a simple local zero-argument helper with an optional docstring and one literal/resolvable return. It never imports or executes the plugin and does not interpret eval, comprehensions, or merges. A schema it can resolve but that is invalid fails preflight; an unresolved dynamic registration is recorded as verified=false, skipped=true, skip_reason=dynamic_ui_schema with its source reference, while runtime registration remains the final fail-closed validator. One frozen compatibility exception is intentionally asymmetric: deterministic preflight reports an iframe declaration with an omitted route as invalid, while runtime registration preserves that route-less shape so an existing extension still loads (the card remains not-supported rather than executable). It does not call any LLM and does not mutate review state, so the agent can iterate without burning review tokens.

skill_preflight(skill="weather")
skill_preflight(skill="weather", paths=["plugin.py"])

Repair task path scheme and edit tools

Repair and run from the Skills or Marketplace UI starts an ordinary managed development task. The confirmed request is retained as a real owner message, not a presentation-only acknowledgement. Its normal task constraint records the selected skill and physical payload root; the task also retains the request source and initial content revision. Persisted mode="skill_repair" task records remain readable with the same ordinary tool capabilities. Payload-relative paths address the selected installation:

Tool Repair path example Use when
read_file / list_files with root=skill_payload plugin.py, scripts/main.py Inspect payload files.
edit_text with root=skill_payload plugin.py One exact replacement in an existing file.
write_file with root=skill_payload new_module.py New files or intentional full-file rewrites.
skill_preflight skill="weather" Cheap read-only syntax/schema check before LLM review.
skill_review skill="weather" Review the changed payload before execution.

The task retains ordinary shell, browser, search, delegation and execution tools. The installed payload is usually a normal directory; an isolated Git copy through delegation is optional. Selected payload operations check the known revision; after an opaque shell command the task records the observed revision without claiming every concurrent change as its own.

Test the repaired installation through its real script, extension tool, HTTP route, widget or companion after the normal review, dependencies and permissions checks. Review does not forcibly unload a working extension. Inspect widget screenshots and repeat the edit/review/execution cycle when fixes are needed.

Repair and run authorizes enabling and testing the repaired installation, leaving it working. After review, the model calls toggle_skill; the host resolves the task's actual original owner message by default. A later direct owner disable requires a newer owner instruction. Repair does not authorize granting all permissions, attestation or deletion. Existing auto-grant policy still applies. skill_owner_action uses the shared lifecycle owners only with the specific action, revision and existing owner-intent source; a source reference alone is not permission. An automatic edit-and-review request retains allow_enable=False and does not gain enablement authority from being a Repair.

Top-level short-form authoring (including light mode)

An ordinary top-level task in every runtime mode can target user-managed skill payloads with root=skill_payload, bucket (external / clawhub / ouroboroshub / user_repo), and skill_name. A markerless payload physically under native/ is selected through the logical external bucket; a true marker-present native seed remains read/review-only. A short relative path such as plugin.py or lib/utils.py resolves under the selected physical payload root. Supply both args together — passing only one returns a clear bucket and skill_name must be supplied together error instead of silently writing into the drive root.

The selected-skill constraint keeps payload-relative calls attached to their declared physical skill. Ordinary development capabilities remain available; selecting another payload is not an implicit redirect of this task's target.

To create a new skill the payload directory need not pre-exist: writing the manifest at the payload root (path="SKILL.md" or path="skill.json") is the authoring signal and provisions the new payload (and marks it self_authored). A non-manifest path into a not-yet-existing payload still errors as a typo guard — write the manifest first, then add the rest of the files.

Equivalent ways to address data/skills/external/weather/plugin.py under light:

write_file(root="runtime_data", path="skills/external/weather/plugin.py", content=...)
write_file(root="skill_payload", path="plugin.py", content=..., bucket="external", skill_name="weather")

Control-plane sidecars (.clawhub.json, .ouroboroshub.json, .self_authored.json, SKILL.openclaw.md) stay blocked for writes and edits either way — the bucket+skill_name short form does not weaken mutation-sidecar protection. The payload-local .seed-origin marker is likewise never writable; the settled direct/read-only native read/list/search contract may inspect ordinary payload markers, while control-state mutation remains closed.

Writing large payload files

The hard ceiling for any single tool call is the LLM output token budget — about a few thousand lines of code, depending on the model and prompt overhead. Two reliable strategies when a generated payload exceeds that ceiling:

  1. write_file(mode="append") in chunks. Each call appends the next slice; the file lands intact across multiple turns. Useful for structured assets (CSV, JSONL, prose corpora) the agent itself is generating.
  2. Split files deliberately. Prefer smaller modules/assets and append generated bulk data in deterministic chunks; this keeps each tool call reviewable and avoids hidden editor loops.

run_command heredoc is not a workaround — every byte of a heredoc body still passes through the same LLM output budget, so it offers no real bypass and is harder to review.

Permissions

The manifest's permissions list authorises specific PluginAPI calls and runtime behaviours:

Permission What it grants
net The skill may make outbound network calls.
fs The skill may write outside its state dir (review item still enforces confinement).
subprocess The skill may spawn child processes (review items + cwd-confinement still enforce).
widget The skill may call register_ui_tab and register_settings_section.
ws_handler The skill may call register_ws_handler and send_ws_message.
route The skill may call register_route.
tool The skill may call register_tool.
read_settings The skill may call api.get_settings([...]).
supervised_task The skill may register an in-process host-supervised async task.
companion_process The skill may register a manifest-declared companion subprocess supervised by the host.
subscribe_event The skill may subscribe to manifest-declared host event topics such as chat.outbound or skill.lifecycle. Chat topics require owner permission grants; skill.lifecycle does not.
inject_chat The skill may request Host Service chat injection after an explicit owner permission grant: POST /chat/inject carries text, an inline image, or attachments ([{path, name?, mime?}] — regular files under the skill's own state root, at most 25 per message, which the host copies without the former 50 MiB upload cap into the shared data/uploads chat-upload store and stages for the task; a file-only message needs no text). The same grant lets the skill relay the owner's decision-card answer through POST /chat/decision ({request_id, decision_id, option_index?, comment?}, the POST /api/decisions contract). A message that carries a client_message_id becomes an addressable operation: the host answers with its operation_ref (<chat_id>:<client_message_id>) on 202, 200 and 504; a repeated delivery of the same message rejoins it instead of enqueueing again (a different message under a reused id is refused with 409); GET /chat/operations/{operation_ref} reports the skill's own accepted message (pending, running with its task or turn, the durable answer, a terminal task status, or lost after a host restart); and POST /chat/cancel ({operation_ref, reason?}) runs the existing cancellation owner on work that message started, answering cancelled, already_terminal, unresolved or cancel_unsupported — never a cancellation that did not happen.
presence A reviewed transport skill may submit authenticated non-owner conversation events to the Host Service Presence boundary and poll only their correlated late work. Requires an explicit content-hash-bound owner grant.

A missing permission causes the matching register_* call to raise ExtensionRegistrationError, surfaced as a skill load error in the Skills UI.

Grants for protected keys and host permissions

Some settings keys are protected: OPENROUTER_API_KEY, OPENAI_API_KEY, OPENAI_COMPATIBLE_API_KEY, ANTHROPIC_API_KEY, MINIMAX_API_KEY, DEEPSEEK_API_KEY, CLOUDRU_FOUNDATION_MODELS_API_KEY, GIGACHAT_CREDENTIALS, GIGACHAT_PASSWORD, TELEGRAM_BOT_TOKEN, GITHUB_TOKEN, OUROBOROS_NETWORK_PASSWORD. These keys are NEVER forwarded to a skill by default, even when listed in env_from_settings. Custom secret keys stored in Settings → Secrets are treated the same way. Host permissions such as inject_chat, presence, and chat event subscriptions also require explicit, content-hash-bound owner consent. The desktop launcher's owner-grant bridge records these grants.

The Skills UI surfaces missing grants on the skill card. The agent can also call toggle_skill enabled=true only after grants are approved (the tool returns SKILL_TOGGLE_ERROR: cannot enable until requested key and permission grants are approved). Self-authored markers alone do not authorize grants or enablement. Automatic grants and first enablement follow the setting below; an explicitly authorized task toggle remains separate.

OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS is enabled by default as of v6.10.0; the owner may disable it in Settings → Behavior → Skills (desktop asks for native confirmation and web uses the owner endpoint). When enabled, a fresh executable review grants only the manifest-declared keys and host permissions for the current content hash. Under blocking enforcement, blocker reviews are not executable and do not auto-grant; under advisory enforcement, blocker findings may auto-grant only because that mode makes the review executable. Editing the skill still invalidates those grants.

Official OuroborosHub skills have one extra review profile. If the installed payload, live catalog file list, and .ouroboroshub.json hashes all match exactly — including the full local runtime-reachable file set — the official_hub profile applies. For such hash-verified official payloads it downgrades severity-driven hygiene/bug findings (bug_hunting, companion_process_safety, extension_namespace_discipline, widget_module_safety) to warnings, so re-reviewing an already-published skill is not blocked by style nits. Hard trust-boundary checklist items still aggregate to blockers, and deterministic preflight, unreadable/binary payloads, sensitive-shaped files, catalog hash mismatches, dependency failures, missing grants, disabled state, and stale reviews still block as usual. Editing the payload locally, adding an extra runtime-reachable file, or any sidecar/catalog hash drift drops back to ordinary local-skill review semantics.

Transport and control skills are first-class control surfaces — a legitimate full replacement for the local UI, not demos. An owner may have no screen, no notebook, and no terminal; the control skill (Telegram today, any remote-control skill tomorrow) is how they run Ouroboros. After fresh executable review, enablement, content-hash-bound token issuance, explicit grants, and owner/chat binding, such a skill is expected to carry every owner command the direct UI accepts (/panic, /restart, /evolve, /bg, /review, /status, and free-form owner text), run a long-lived poller (supervised_task), and observe the owner-conversation events it mirrors. Power is not a defect: review judges the skill's actual safety properties — owner/chat binding, trustworthy source attribution, bounded polling/backpressure, cleanup on unload/panic, host-token confinement, and no exfiltration of secrets or owner-conversation content to unrelated parties — never the breadth of control it exposes. The capability is gated by the host (token + fresh review + enablement + content-hash grants) and by core owner/chat binding, not by withholding control from the skill. See docs/CHECKLISTS.md → "Transport and control skills are first-class".

Presence behavior profiles and transport ingress

Presence separates two ordinary reviewed skills: a behavior skill declares how an external conversation should be handled, while a transport skill authenticates provider events and relays them through the loopback Host Service. The behavior is portable; provider credentials, room ids, exact tool names, and installation-specific resource locations do not belong in its profile.

A behavior skill may add a strict presence: block to its manifest:

presence:
  schema_version: 1
  instructions_file: presence.md
  context_topics:
    - public-conversation-notes
  runtime_defaults:
    model_slot: main             # main | light
    inline_max_rounds: 10
  capability_requests:
    - id: research
      kind: tool                 # tool | script | resource
      required: false
      purpose: Look up current public information when a reply needs it.
    - id: notes
      kind: resource
      required: true
      operations: [read, write]
      purpose: Maintain conversation notes in a selected confined resource.

Use either non-empty inline instructions or instructions_file, never both. An instructions file must be UTF-8 inside the reviewed payload surface. context_topics names full knowledge topics to inject for each turn. runtime_defaults is optional and resolves to a bounded main or light turn; the host clamps inline_max_rounds to its global limit. Capability request ids are stable portable concepts. After installation, configure_presence maps them to exact built-in, extension, MCP, script, or confined resource targets; required requests must all be selected before admission. These selections and runtime overrides are host-owned state. Changing a request's id, kind, or resource operations invalidates its selection instead of silently retargeting it.

A transport extension declares permissions: [presence], obtains its ordinary content-hash-bound skill token, and sends:

  • POST /presence/turn with exactly binding_id, event, and optional staged_files. The event carries the provider/account/conversation/thread, stable source-event and conversation ids, structured actor/conversation/message facts, and text. Files must already be under that transport skill's state root.
  • GET /presence/work/{work_ref}?binding_id=... to poll only late work created by the same owner binding.

The owner-created binding fixes the authenticated transport skill, behavior skill, origin scope, and exact proactive destination. The origin is either one exact conversation/thread or the explicit account-wide conversation id *; the latter admits any conversation on that exact provider account while each event still receives its own canonical conversation key. For each turn the host rechecks install, enablement, executable review, binding identity, and required selections, then compiles an immutable positive capability ceiling. A fresh agent receives only that ceiling, reviewed instructions, exact event facts, and declared context topics. Completion is typed as message, silent, tool_delivered, or deferred; deferred includes a correlated work_ref only after successful promotion. Promotion and one-shot or recurring follow-up keep the same ceiling and reply context rather than widening authority.

Transport custody preserves provider arrival order before Host admission; the host serializes one conversation and enforces the installation-wide active-turn limit across processes. A current Presence turn may cancel only its own binding-and-conversation-correlated work_ref. Owner chat or Background Consciousness may initiate an existing binding, but the resulting cycle must use an explicitly selected transport tool and finish tool_delivered to claim that an external message was sent.

Notifying the owner when work completes

Long-running or user-visible skills should make completion and failure visible. For type: script skills, skill_exec appends skill_exec_finished or skill_exec_failed records to logs/events.jsonl and publishes them on the skill.lifecycle event topic with skill, script, exit_code, and error fields where relevant. Extension skills may declare:

permissions: [subscribe_event]
subscribe_events: [skill.lifecycle]

skill.lifecycle is not a chat-content topic, so it does not require an owner permission grant. Skills that perform multi-step external work should still print a concise success/failure marker or write structured state under OUROBOROS_SKILL_STATE_DIR so the agent can decide whether to fix or report.

Iterative skill development

The recommended closed-loop workflow is:

  1. Edit the skill payload under data/skills/external/<name>/, data/skills/clawhub/<name>/, or data/skills/ouroboroshub/<name>/.
  2. Run skill_preflight(skill="<name>") for cheap syntax/manifest checks.
  3. Run skill_review(skill="<name>") and address every critical finding.
  4. If OUROBOROS_REVIEW_ENFORCEMENT=advisory, inspect each advisory finding and either fix it or record why it is accepted for now.
  5. Enable the skill, grant required keys/permissions (or use the auto-grant setting for reviewed closed-loop development), then run skill_exec.
  6. Read stdout/stderr and skill_exec_finished / skill_exec_failed events, fix the payload, and repeat until the skill works.
  7. For a visible widget or Settings change, after enablement open at least one relevant real consumer flow in an available browser and inspect the rendered evidence with vision. A saved screenshot that was not viewed is not verification. Choose states, viewports, and additional engines by risk; mobile/WebKit are not a universal matrix. If evidence you judge necessary is unavailable, report the result as degraded/best-effort and name the gap.

PluginAPI reference

The frozen ABI is documented in ouroboros/contracts/plugin_api.py. This section shows the practical shape.

Extensions declare the PluginAPI generation they bind against in the manifest (PluginAPI 2.0, ABI 7.0):

plugin_api: "2.0"            # major strict; minor = required minimum
# or, with required capabilities (closed set, validated per execution mode):
# plugin_api:
#   version: "2.0"
#   capabilities: [register_tool, subscribe_event]

A payload without the field binds the legacy "1.3" generation by construction: an already-reviewed payload keeps loading on its existing hash-bound review PASS, but a NEW review PASS (LLM review, owner attestation, or native-seed trust) is refused until the field is declared.

def register(api):
    # Tools — agent-callable, namespaced as ext_<len>_<token>_<name>.
    api.register_tool(
        "search",
        handler=do_search,
        description="Web search",
        schema={
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
        timeout_sec=60,
    )

    # HTTP routes — mounted at /api/extensions/<skill>/<path>. GET/HEAD under
    # manifest, module/... and settings_section are host-owned (see "Loading
    # more than one file").
    api.register_route("search", handler=http_search, methods=("POST",))

    # WebSocket message handlers (inbound) and broadcasts (outbound).
    api.register_ws_handler("ping", handler=ws_ping)
    api.send_ws_message("event", {"hello": "world"})

    # Widget UI tab on the Widgets page.
    api.register_ui_tab(
        "live",
        title="Search",
        render={
            "kind": "declarative",
            "schema_version": 1,
            "components": [...],
        },
    )

    # Settings sub-section on the Settings page (v5.7.0+).
    # Settings sections use a narrow declarative subset: form/action for
    # configuration writes and markdown/json for explanatory diagnostics.
    # Rich widget-only components (media, stream, map, kanban, module JS)
    # belong on the Widgets page, not Settings.
    api.register_settings_section(
        "config",
        title="Search settings",
        schema={"components": [
            {"type": "form", "route": "config/save", "method": "POST", "fields": [...]},
        ]},
    )

    # Cleanup callback when the extension is unloaded / disabled.
    api.on_unload(close_pool)

    # Read-only runtime info (v5.7.0+).
    info = api.get_runtime_info()
    # {runtime_mode, app_version, data_dir, server_port, skill_dir, state_dir,
    #  execution_mode, capabilities}  # execution_mode/capabilities: negotiate OOP availability

    # Read settings keys allow-listed in env_from_settings.
    keys = api.get_settings(["OPENROUTER_API_KEY"])

Async tool handlers (v5.7.0+)

Tool handlers can be plain functions OR async def coroutines — the registry detects coroutines and runs them on a helper thread with a fresh event loop under asyncio.wait_for(timeout_sec). They do not execute on the server's main event loop, so do not rely on loop-local state captured at registration time. HTTP routes and WS handlers have always supported async; v5.7.0 closes the asymmetry for tools.

async def do_search(ctx, query: str = ""):
    async with httpx.AsyncClient() as client:
        resp = await client.get(...)
    return resp.text

api.register_tool("search", handler=do_search, description=..., schema=...)

kind: "module" widgets (v5.7.0+)

For surfaces the declarative components cannot express, ship a widget.js mounted inside a sandboxed <iframe srcdoc>:

ui_tab:
  tab_id: editor
  title: Editor
  render:
    kind: module
    entry: widget.js
    start: manual                   # auto | manual | retain — see "Launch policy" below

The manifest declaration is checked during preflight and review; it does not create a live tab. With permissions: [widget] in your extension manifest, register the same surface in plugin.py:

def register(api):
    api.register_ui_tab("editor", "Editor", render={
        "kind": "module", "entry": "widget.js", "start": "manual",
    })

register_ui_tab creates the tab on the Widgets page after the extension loads. Keep widget.js beside plugin.py; it renders into the provided #root element.

The host fetches reviewed JS through GET /api/extensions/<skill>/module/<entry>, embeds it in an opaque-origin iframe (sandbox="allow-scripts allow-pointer-lock allow-downloads", never allow-same-origin; see "What the frame may do" below), and injects the host bridge (window.OuroborosWidget, below) that forwards only /api/extensions/<skill>/... requests and the skill's own WebSocket events. The widget_module_safety review item still checks the source; do not rely on the sandbox alone.

Framed render declarations may add a bounded height (320–8,192 pixels). When a module omits height, the host starts at 320px and measures its existing #root through a nonce-bound resize message. The host integer-deduplicates and clamps that value to 8,192px by default; an optional module-only max_height lowers the ceiling. A fixed height disables auto-growth. Legacy route iframes accept explicit height only because the host cannot inspect their opaque document. The parent owns iframe removal and the module bootstrap rejects pending fetch promises and errors open body streams on disposal, so module code must not invent a second resize, vertical-scrolling, or teardown protocol: the host-owned dispose → acknowledgement handshake described under "Launch policy" below is the teardown protocol, and window.__ouroWidgetOnDispose(fn) is the only hook into it. These geometry keys are valid only for framed iframe and module renders; declarative renders remain content-driven and reject them.

For auto-height module CSS, prefer one owner of padding and box geometry between body and #root, and choose border-box deliberately. Nested percentage min-height/padding owners, especially with overflow-x: hidden, can compute overflow-y: auto and feed scrollbar width back into wrapping. This is author guidance, not a restriction: legal width-sensitive CSS remains supported by the host-owned resize contract. For auto-height modules, the host bootstrap suppresses only document-viewport overflow-y below the ceiling and releases it at the ceiling; horizontal document overflow remains author-controlled and reachable.

The in-frame bridge (window.OuroborosWidget)

The frame has no scriptable network of its own: connect-src stays closed, so XMLHttpRequest, WebSocket, EventSource and beacons are refused by the document policy, and every request goes through the parent over one nonce-bound message grammar (passive image, media and font loads from your own route prefix are the one exception — "What the frame may do" below). The bridge exposes:

  • OuroborosWidget.fetch(url, init) (also installed as the frame's fetch). url must resolve under /api/extensions/<skill>/...; anything else — another skill's prefix, a host API, an absolute URL — rejects with module widget fetch outside extension route prefix. The parent issues the request with the owner's session and refuses to follow a redirect — the prefix is checked once, before the request, and a followed hop would carry your request and the owner's session wherever it pointed — so a route that answers with a redirect rejects instead of being followed; it streams the answer back, so you get a real Response: status, statusText, every response header, and a body that is binary by default. Each next chunk is read only when your consumer requests it — .text(), .json(), .arrayBuffer(), .blob() and incremental body.getReader() reads all work. Server-sent events are a plain streaming GET with Accept: text/event-stream read through body.getReader() (there is no EventSource polyfill); NDJSON works the same way. HEAD and 204/205/304 answers carry a null body. init.method, init.headers and init.body (string, ArrayBuffer, typed array or Blob) pass through. There is no default timeout: a request or stream lives until it ends, until you abort it, or until the frame is disposed. init.signal (an AbortController) or cancelling the body stream aborts the parent's request; the optional init.timeoutMs is an author-side bound that aborts it for you (the read fails with widget request timed out).

  • OuroborosWidget.onEvent(callback) returns an unsubscribe function. The callback receives {type, data} for every event this skill emits with api.send_ws_message(type, data) — type is the short name you passed; the host strips its own namespace prefix. The first listener subscribes the frame, the last unsubscribe stops delivery, and other skills' events never reach it.

  • OuroborosWidget.download(name, source) saves an existing Blob, a data: URL, or a URL under this skill's extension route prefix. It resolves to the host's delivery result or rejects with a visible error. In the desktop app it uses the same native Downloads owner as other file controls; in a browser success means the download was started, not that disk writing was confirmed. Large backend files should be passed as route URLs so the host does not turn an HTTP stream into a Blob. Ordinary <a download> controls using these routes, data: URLs or frame-created Blob URLs use this same path.

Out-of-process routes execute standard Starlette responses in the child and stream their ordered headers and body to the host, including FileResponse HEAD/Range behavior and background actions. There is no total or pre-header request timer: finish, abort, disconnect or unloading that skill instance ends its response. Slow consumption is backpressure, not a timeout. A body failure breaks the stream; cleanup failure after the complete body is logged separately. The incoming request body retains its 512 KiB cap, and one-shot tool/catalog/WS results retain their existing caps and timeouts.

The out-of-process / companion WS push (POST /ui/ws-message) admits a 60-message burst per skill and then one message per second (the excess gets a 429 with retry_after_sec, and the host logs each refused burst once with its dropped count), so throttle sustained progress streams or fall back to poll-based status.

What the frame may do

Both framed mounts — the module srcdoc frame and a kind: iframe route frame — carry one capability set, decided for all installs: sandbox="allow-scripts allow-pointer-lock allow-downloads", allow="autoplay; fullscreen; clipboard-write" and allowfullscreen. Never allow-same-origin (the frame stays an opaque origin: no SPA cookies, storage or DOM), never top navigation, popups, forms (form-action does not fall back to default-src, so a form submit would be an exfiltration channel), modals or clipboard read.

The module frame's document policy, built by the host from the page origin (an opaque frame's 'self' matches nothing, so sources are absolute):

default-src 'none';
script-src 'unsafe-inline' 'wasm-unsafe-eval' blob: <origin>/api/extensions/<skill>/module/;
worker-src blob:;
style-src 'unsafe-inline';
img-src data: blob: <origin>/api/extensions/<skill>/;
media-src data: blob: <origin>/api/extensions/<skill>/;
font-src data: blob: <origin>/api/extensions/<skill>/

What that gives you, verified on Chromium and WebKit through tests/test_widgets_ui_browser_capabilities.py:

  • Sibling scripts from your module prefix, classic (<script src>) or import() — "Loading more than one file" below.
  • WebAssembly: 'wasm-unsafe-eval' admits WebAssembly.instantiate and instantiateStreaming on bytes your own route serves — the recipe below.
  • Workers from blob: URLs (new Worker(URL.createObjectURL(new Blob([src])))); importScripts inside one may load from your module prefix.
  • Images, audio, video and fonts from your own route prefix and from data: / blob: URLs — "Assets" below, including the CORS rule for fonts.
  • Clipboard write (navigator.clipboard.writeText) from a user click; the clipboard is never readable from the frame.
  • Downloads: module widgets use OuroborosWidget.download or ordinary <a download> controls for their own route files, data URLs and frame-created Blobs; the existing host save path supports both the desktop app and browsers. A legacy kind: iframe route page has no module bridge: its downloads still depend on the embedding engine. Use a module widget or a host-side declarative file component when a native Downloads handoff is required.
  • Pointer lock (allow-pointer-lock) and fullscreen (allowfullscreen + allow="fullscreen") for games and emulators. Both need a user gesture and a focused window; feature-detect with document.fullscreenEnabled, which is true in Chromium-based engines (browsers; the Windows shell's WebView2) but false in WebKit (the macOS desktop shell): WebKit fails the Fullscreen permission-policy check for an opaque-origin frame.
  • Autoplay is allowed by the frame's policy; the browser's own autoplay rules (a user gesture for audible playback) still apply.

A kind: iframe route frame is your own page under the same sandbox and permissions set, with no bridge and no host CSP: its scripts may use the network exactly as your skill's backend already can, without the SPA's cookies or DOM. Because its origin is opaque, its fetch calls are cross-origin: a route it reads must answer with Access-Control-Allow-Origin: * (or be requested with mode: "no-cors" for a fire-and-forget opaque response), and on a network install its requests carry no session cookie either.

What the module frame does not give you, by design: a scriptable network (connect-src is closed — use OuroborosWidget.fetch), eval/new Function (there is no 'unsafe-eval'; WebAssembly is the sanctioned compiled-code path), and any load from another skill's prefix or a foreign origin (the document policy refuses it and dispatches a securitypolicyviolation event you can observe).

Launch policy (render.start)

A widget card declares how it starts with render.start. The validator in ouroboros/extension_ui_validation.py (WIDGET_START_MODES) is the single source of truth for the allowed values and fills the default into the stored declaration, so every framed or declarative widget tab carries an explicit value (a tab without a render has nothing to launch):

start Behaviour Default for
auto Starts when the Widgets page is shown; leaving the page stops it. For cheap instruments (a quota gauge, a status board). declarative — the only value it accepts: the host draws it, there is nothing to start
manual The card shows the title, icon, and a Start button; the program runs only after the owner presses Start. Leaving the page is an ordered Stop: for kind: module the host sends the dispose message and gives the widget up to one second to save before the frame is removed; a kind: iframe route frame has no bridge and is removed at once. module, iframe
retain "Keep running": starts on the first Widgets visit like auto and keeps running while the owner is on other pages; the card's status reads "Keeps running". It stops on the owner's Stop, on skill disable / unload / delete (also while Widgets is hidden), when the window reloads, and when Ouroboros closes. A server reconnect with the same served code keeps the frame when the skill is live again with the same revision; a changed revision stops it in order and starts it again. —

Rules every module author follows:

  • icon is one glyph — an emoji or a symbol character — shown beside the title on a stopped card's facade. An identifier-like name (cloud, gamepad, the extension default) is not a glyph: the host has no named-icon set, does not render the word, and shows its own widgets glyph instead.
  • Declare start explicitly for a heavy program. A game, emulator, or simulation that should not run all the time is manual; only a program that genuinely must keep running while the owner is elsewhere — and that stays cheap while hidden — is retain. Omitting the key gives a framed widget manual. An existing module or iframe widget whose declaration omits start therefore now renders as a stopped facade with a Start button until either the author republishes it with start: "auto" or the owner selects Auto in the card's menu.
  • The owner always wins, and Stop always wins. The owner can change any card's mode from the card; that choice is stored in ui_preferences.widget_start_mode ("<skill>:<tab_id>" → mode) and overrides your declaration. Stop is always available and wins over every mode; do not build your own keep-alive or restart logic against it.
  • The view is disposable — durable state lives in the skill. Treat the frame like an editor tab (VS Code's getState/setState model): autosave through your own /api/extensions/<skill>/... routes while running, and register window.__ouroWidgetOnDispose(fn) — the hook may be async — to flush what is left. Register with the function; never assign over it. The declared handshake is the teardown protocol: the host posts the dispose message, your hooks run and may finish bridged requests within one second, the bootstrap acknowledges, and only then is the frame removed. localStorage and cookies throw in the opaque origin; never keep state only in the frame.
  • retain is not a daemon. It never survives Ouroboros closing: closing the app ends every widget together with every other Ouroboros process, and a page reload ends every widget too — a frame cannot outlive the page that hosts it. Retained instances are per browser client, not a singleton — a second window or device runs a second instance. A program that must be a singleton, be supervised, or be independent of any window is a companion_process, not a widget.
  • Hidden pages are throttled by the browser, not by the host. In Chromium-based browsers animation frames pause while the page is hidden; timers, audio, and bridged requests continue at the rate the browser allows. The macOS desktop shell (WKWebView) does not throttle hidden frames; the Windows shell (WebView2) and ordinary Chromium browsers pause animation frames while hidden. No tick rate is promised. Keep work that must progress off requestAnimationFrame.
  • Install and enable never start browser code. The first visit to Widgets does; nothing runs at app load.

What the host does today: all three policies are honoured — an auto card mounts when Widgets is shown and stops when the owner leaves, a manual card waits behind its Start button, and a retain card starts on the first visit and stays mounted while the owner is elsewhere with a "Keeps running" status until Stop, the skill leaving the live list (even while Widgets is hidden) or the window going away; the owner's per-card override wins over your declaration; and the dispose → acknowledgement handshake is live for kind: module: your __ouroWidgetOnDispose hooks may be async and may use the fetch bridge, and the parent gives them up to one second before it removes the frame — on Stop, on leaving the page, and when your skill's revision changes while the card runs (the old frame flushes first, then the fresh card mounts). A kind: iframe route frame has no bridge and is removed at once. Autosave while running plus the one-second flush is still the whole durable path — nothing survives a reload or Ouroboros closing, kept-running cards included. When the owner disables your skill while its widget runs, the dispose hook still runs, but the server unregisters the skill's routes before the lifecycle event reaches the page, so a bridged request to your own route from that hook may already answer 409 — autosave while running remains the durable path.

WebAssembly (.wasm) in the payload

A skill may ship WebAssembly modules as ordinary payload files. Review admits them descriptor-admitted, content-hash-bound: the review pack carries a {path,size,mime_from_name,sha256} descriptor for each .wasm file — the review pack never inlines the WebAssembly bytes (an agentic reviewer may still open a reachable binary by path) — and the payload content hash covers every byte, so changing one byte of a module stales the stored review exactly like editing widget.js. The admission exists because WebAssembly executes only inside the browser's sandboxed widget frame, never natively in the host process; native loader magics (ELF, PE, Mach-O, .pyc) remain hard review blockers. Reviewers judge the JavaScript that instantiates the module and the module's provenance instead of its bytes.

Ship and load it through your own route: register a route that returns the module bytes with a Starlette Response or FileResponse. The same response runs in an isolated child for dependency-bearing skills and streams without the old serialized-result body cap. Then in the widget:

const bytes = await (await OuroborosWidget.fetch('/api/extensions/<skill>/core.wasm')).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, imports);

WebAssembly.instantiateStreaming(OuroborosWidget.fetch(url)) works too (the bridge hands back a real Response; serve the module as application/wasm). The module endpoint (GET /api/extensions/<skill>/module/...) stays JavaScript-only; binary assets always travel through the skill's own routes. The frame CSP admits this with 'wasm-unsafe-eval' — there is no plain 'unsafe-eval', so WebAssembly is the one compiled-code path.

Assets: fonts, audio, video, images

Widget assets are ordinary payload files and travel the same way as WebAssembly: your own routes serve them (register_route returning a standard response, streamed from an isolated child when required), the widget references them by /api/extensions/<skill>/... URL, and review sees each non-text asset as a content-hash-bound descriptor. The module endpoint stays JavaScript-only. Hub packages admit .png .jpg .jpeg .gif .webp .svg, .mp3 .ogg .wav, .mp4 .webm, .woff .woff2 .ttf .otf, and .wasm. ClawHub archives are capped at 8 MiB per file, 50 MiB uncompressed in total, and 200 files (ouroboros/marketplace/fetcher.py); OuroborosHub catalog files at 5 MiB each (ouroboros/marketplace/ouroboroshub.py). A large runtime image — a v86 disk image of several megabytes and up — does not fit a package: have the installer fetch it through an exact download spec and serve it from <skill_dir>/.ouroboros_env/resources/ (skill_dir comes from api.get_runtime_info()). The existing runtime-download path into the skill state directory remains available for dynamic data. Declared automatic dependencies select the existing process executor; persistent subscriptions and long-lived work still use the documented companion facilities. Locally installed skills have no per-file cap; the review pack budget is the only bound. The frame's img-src/media-src/font-src admit your skill's route prefix, so <img src="/api/extensions/<skill>/logo.png">, <audio src> / <video src> and @font-face { src: url(...) } load straight from your routes. Two rules come with that:

  • Fonts need the CORS header. The frame is an opaque origin, so @font-face (like import()) is a CORS-mode fetch: a font route must answer with Access-Control-Allow-Origin: * or Chromium-based browsers refuse the face (FontFace.status === "error"; WebKit is lenient, so test on Chromium). Images and media are plain no-cors loads and need no header; the module endpoint already sends it for scripts.

  • Passive loads carry no session on network installs. On a password-protected install reached over the network (not loopback, not the desktop shell), the owner's session cookie is SameSite=Lax and an opaque frame's <img>/<audio>/<video>/@font-face requests are cross-site, so they arrive without it and get 401. Loopback and the desktop shell are exempt. For an asset that must work everywhere, go through the bridge — the parent sends the session — and hand the bytes to the element as a blob: URL, which img-src/media-src/font-src admit:

    const blob = await (await OuroborosWidget.fetch('/api/extensions/<skill>/logo.png')).blob();
    img.src = URL.createObjectURL(blob);
    

Loading more than one file

Every reviewed .js/.mjs file in the skill directory is served by the module endpoint, keyed by its path relative to the skill directory: GET /api/extensions/<skill>/module/lib/x.js. The host captures all of them when the module tab registers (the same moment it reads the entry), so the frame always receives the bytes the reviewed bundle loaded from; files under node_modules, .ouroboros_env, other cache directories, and dot-prefixed paths (directories and files) are never served, and only UTF-8 text is admitted — a non-UTF-8 .js fails the load exactly like a broken entry; the .js/.mjs suffix match is case-sensitive. The host owns GET/HEAD for the exact paths manifest and settings_section and for everything under module/ in /api/extensions/<skill>/, so do not register skill routes there: a route registered at those paths is shadowed for GET/HEAD, while POST and the other methods are unaffected. Load a sibling either as a classic script or as an ES module:

<script src="/api/extensions/<skill>/module/lib/x.js"></script>
const { helper } = await import('/api/extensions/<skill>/module/lib/x.mjs');

The endpoint sends Access-Control-Allow-Origin: *, which the opaque-origin frame needs for import(); relative specifiers inside a module loaded this way resolve against its URL, so import './y.mjs' reaches module/lib/y.mjs.

A sibling loaded this way carries no session, for the same reason passive image and font loads do not (above): on a password-protected install reached over the network, the frame's request for module/lib/x.js is cross-site, arrives without the SameSite=Lax session cookie and gets 401. The declared entry is unaffected — the host fetches it with the owner's session and inlines it — and loopback and the desktop shell are exempt. For a skill that must load siblings on a network install, fetch the source through the bridge and run it from a blob: URL, which script-src admits:

const src = await (await OuroborosWidget.fetch('/api/extensions/<skill>/module/lib/x.js')).text();
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
await import(url);   // relative specifiers inside x.js no longer resolve: import by URL

Alternatively keep everything the widget needs in the entry file. The declared entry itself still executes as a classic script even when it is named .mjs, so keep import/export statements in the files you load with import(), not in the entry. The frame's script-src admits exactly your module prefix (<origin>/api/extensions/<skill>/module/), blob: URLs and inline scripts — a script from any other path or skill is refused by the document policy. Hub packages are bounded by the caps above; a locally installed skill has no per-file cap, so its captured JavaScript is bounded only by what you ship.

For everything else, prefer declarative components (form, action, poll, subscription, stream, table, chart, markdown, json, kv, status, tabs, progress, media/file/gallery, map/calendar/kanban, group, metric, and callout). They handle XSS, CSRF, and lifecycle automatically. subscription.render is transitively passive and may contain display children only; never nest interactive or mutating lifecycle components anywhere below it.

Widget composition rules

The host validates the full component tree before load, to a maximum depth of 8 and 256 nodes; a failure reports the exact tree path. group.components and tabs[].components may contain interactive components. Give a component an explicit id when it needs a durable author-facing identity; otherwise the host uses its stable tree path. Never rely on a top-level array index for lifecycle identity.

Nested composition still has one strict passive boundary: subscription.render cannot contain form, action, poll, stream, another subscription, or mutating kanban, even through nested groups or tabs. One widget-level disposer owns timers, streams, abort controllers, charts, and snapshots, and inactive tabs do not restart lifecycle work. Job widgets keep their job_id across bounded retryable transport/server failures and request timeouts; explicit terminal states clear it. Skill declarations cannot supply arbitrary HTML/JavaScript/CSS, raw chart options, colors, selectors, or cross-widget bindings.

The additive schema-v1 composition components are intentionally small:

Type Author contract
group components, optional title / description / condition_key, and `layout: stack
metric label, either literal value or data path, optional unit / precision, and a closed semantic tone; missing, unknown, or non-finite data renders the standard empty value, never numeric zero.
callout Literal text or data path, with closed tone `info

Forms use the host field renderer for safe columns/spans, placeholder/help, numeric min/max/step, disabled/busy labels, and duplicate-submit protection; password values are not retained across renders. Table presentations are the closed number/status/link set and an unsafe URL renders as text. Charts keep unknown/non-finite values as null, use spanGaps=false, and expose an ARIA label plus an expandable same-data semantic table (also the Chart.js fallback). Kanban drag/drop and the native Move to control call the same on_move route with {card_id, column_id} and share busy/error state. A status component can also observe refreshing (v6.71.0): a background poll refetch that already has data keeps the content and shows a thin indicator instead of a loading swap — your declared loading label is reused unless you declare a refreshing one.

Async job error contract

Long-running widget actions follow the declarative async job contract: start route returns job_id; status route normally returns queued, running, done, or error; the host resumes polling by job_id after tab switches. Transport/408/429/5xx failures and the host's bounded request timeout retain the job id and retry on the existing interval. A missing or malformed status envelope is shown as an immediate protocol error; a non-empty vendor-specific in-progress status remains pending until the existing max_ticks bound. If you use asyncio.gather(..., return_exceptions=True), convert exceptions into an explicit job failure instead of only logging them:

results = await asyncio.gather(*tasks, return_exceptions=True)
errors = [item for item in results if isinstance(item, Exception)]
if errors:
    job["status"] = "error"
    job["error"] = "; ".join(str(error) for error in errors[:3])
else:
    job["status"] = "done"

Skill Review Checklist

Reviewers grade your skill on the checklist defined in docs/CHECKLISTS.md §"Skill Review Checklist". That file is the authoritative SSOT — read it there once and consult it whenever you author or repair a skill instead of reading a paraphrase here. Review verdicts are clean, warnings, blockers, or pending; execution is decided by review_gate.executable_review.

Reference skills

The simplest reference for each type lives in the OuroborosHub catalog (razzant/OuroborosHub); unix_computer_use ships bundled under repo/skills/:

  • weather — type: extension, declarative form/key-value widget, reads no env keys.
  • duckduckgo — type: extension, declarative form widget, no env keys, declares the ddgs Python package as an isolated dependency.
  • perplexity — type: extension, declarative form widget, read_settings for OPENROUTER_API_KEY.

You can read their full source under data/skills/ouroboroshub/<name>/ once installed from the hub, under data/skills/external/<name>/ for your own local skills, or by browsing razzant/OuroborosHub on GitHub.

Publishing

OuroborosHub (official, curated)

razzant/OuroborosHub is the official catalog. Finish the local payload and configure GITHUB_TOKEN in Settings → Secrets, then choose Publish to OuroborosHub on the skill card or ask Ouroboros in chat. This is one agent-driven flow; a separate manual Preflight or Review action is not required merely to start it. The browser first runs a read-only preflight for the selected skill and asks for explicit public-action confirmation. Cancelling creates no task.

The preflight returns one of five states:

  • ready — the current snapshot is locally publication-ready; the managed task repeats the authoritative checks before any public effect.
  • warnings — only non-blocking redacted findings remain, and continuing requires explicit confirmation.
  • needs_attention — the content, version, or full review still needs work, but the ordinary managed publication task may start so Ouroboros can repair and re-review it.
  • repairable — Betterleaks is missing or unhealthy; the ordinary task may start with the exact repair hint.
  • hard_block — authority, identity, source, or managed-task admission prevents the task from starting.

If the selected user_repo leaf lost its manifest after the card was opened, that exact skill_publish task may omit bucket while it lists/searches/reads the leaf and while it creates only the root SKILL.md or skill.json. Once the manifest exists, ordinary discovery, preflight, and fresh review resume. Grouping directories, unknown or colliding identities, nested manifests, and path escapes are still refused; no parallel external payload is created.

An ordinary .env.example is included in the payload hash, review and publication snapshot, including after ClawHub archive import. Its name does not exempt its contents from the existing publication scan.

Only literal Betterleaks high confidence blocks an outbound publication call. medium, low, missing, and unknown confidence remain redacted warnings. For an intentional provider-shaped fixture, Ouroboros may add Betterleaks's exact-line betterleaks:allow annotation. That byte edit makes the hash-bound skill review stale, so a fresh full skill_review is mandatory before retrying. The audit pass records the suppressed exact line as an audited false positive. Remove or rotate a real credential instead of allowing it.

Packaged installs include Betterleaks 1.8.1. A source checkout installs the exact pinned runtime explicitly with:

python -m ouroboros.betterleaks_runtime install

Publish never downloads the scanner automatically. Typed failures return the completed stage, external effects, and a repair hint; Ouroboros decides whether to inspect, repair, re-review, retry, clean up, or stop.

A successful publication also writes a durable local receipt to data/state/skills/<name>/ouroboroshub.json (published section: slug, version, content hash, repository, PR number/url, timestamp). The receipt is best-effort: a write failure is disclosed as publication_recorded: false in the tool result and never cancels the real PR. The Skills UI reads it for the "Submitted PR #N" badge and the adopt confirmation copy; it survives uninstall and adopt, and a republish overwrites it.

If a submission is no longer being pursued, Clear local submission on its OuroborosHub card removes that local waiting state. The action preserves the installed files, review and grants, and does not close or change the GitHub PR. It clears the receipt shown on the card; if another publication replaced it, refresh the card first. Returning to My skills refreshes the submission badge.

For a catalog update, the tool result and PR body name both the current catalog version and the proposed version. Versions are opaque strings: an older-looking or non-semantic version is allowed, while an identical version still needs to change before publishing an update.

Publication succeeds only when the task records a validated pull-request receipt in the configured Hub repository for this exact skill. A branch, commit, refusal report, or unfinished attempt is partial progress, not successful publication. The pull request includes only a redacted scanner attestation: engine and version, ruleset digest, blockers=0, and warning/audited counts. It never includes candidate values, snippets, or raw scanner output.

ClawHub (third-party, registry-driven)

ClawHub is the broader OpenClaw registry. Publishing requires an OpenClaw publisher account; once your skill is on the registry the ClawHub tab in the Marketplace will install it via the adapt_openclaw_skill translation pipeline. Note that the adapter preserves the original SKILL.openclaw.md next to the translated SKILL.md so reviewers can cross-check both manifests.

Migration patterns

When you bump the schema your state_dir/ files use, run the migration in your register(api) (idempotent, fast). Example:

def register(api):
    state = pathlib.Path(api.get_state_dir())
    legacy = state / "legacy_db.json"
    modern = state / "db_v2.json"
    if legacy.exists() and not modern.exists():
        modern.write_text(_migrate(legacy.read_text(encoding="utf-8")), encoding="utf-8")
        legacy.unlink()
    # ... continue registration

Troubleshooting

Symptom Likely cause
SKILL_EXEC_BLOCKED: review status is 'pending' Run skill_review for this skill.
SKILL_TOGGLE_ERROR: dependency fingerprint is stale Re-run skill_review; post-review deps reconciliation will reinstall.
EXTENSION_NOT_LIVE on tool dispatch The skill is disabled or the loader had a load_error — check the Skills UI.
SKILL_REPAIR_STALE: ... The selected payload or its admission revision changed; inspect the current state and the reported conflict before continuing.
PluginAPI.register_* raises ExtensionRegistrationError Usually the skill is missing the matching permission in its manifest. For register_companion_process the name must also be alnum/underscore and declared under companion_processes — see "Declaring a companion process".
Reviewer marks widget_module_safety: FAIL widget.js fetches outside /api/extensions/<skill>/, talks to the parent through its own postMessage protocol, declares a start mode heavier than the widget needs, or keeps state only inside the frame. Move data through your own routes and save it from __ouroWidgetOnDispose (autosave while running until the host's dispose acknowledgement ships).

For deeper integration questions read docs/ARCHITECTURE.md §13 (external skills layer) and docs/CHECKLISTS.md §"Skill Review Checklist".