iii / worker
$worker

skills

v0.2.4

Agentic content registry worker. Hosts skills + prompts + the iii:// resource resolver that the mcp worker serves to harnesses.

  • macOS: arm64 · x64
  • Linux: arm64 · armv7 · x64
  • Windows: arm64 · x64 · x86

install

install
$iii worker add skills

configuration

iii-config.yaml
- scopes:
    prompts: prompts
    skills: skills
  state_timeout_ms: 10000
README.md

skills

Agentic content registry worker for the iii engine. Persists two kinds of content for AI clients: skills (markdown orientation docs about a worker's tools, served at iii://{id} plus an auto-rendered iii://skills index) and prompts (parametric slash-command templates, dispatched on demand to a registered handler function). Workers register their content at boot via iii.trigger; skills persists everything to iii-state and emits change notifications on skills::on-change / prompts::on-change for any other worker that wants to react.

Surface What clients see When to use it
Skills Markdown documents under iii://{id} plus an iii://skills index Orientation: "when and why to use my worker's tools"
Prompts Slash-commands (e.g. /send-email) with declared arguments Parametric command templates the user invokes

The rest of this README walks you through installing the worker and writing a worker that publishes skills and slash-command prompts.


Table of contents

  1. Install
  2. Quickstart: publish a skill and a slash-command
  3. Configuration
  4. Functions
  5. Custom trigger types
  6. Local development & testing

Install

iii worker add skills

iii worker add fetches the binary, writes a config block into ~/.iii/config.yaml, and the engine starts the worker on the next iii start.


Quickstart: publish a skill and a slash-command

A worker can plug into this registry three ways. They compose: most workers ship one top-level skill, optional sub-skill markdown sections, and zero or more slash-commands.

The interface is two iii.trigger calls, plus optional in-process handler functions for sub-skills and prompt rendering:

Step Function id What it stores
Register a skill skills::register Markdown body keyed by id
Register a prompt prompts::register Slash-command name + args + handler function id
Subscribe to changes skills::on-change / prompts::on-change Custom trigger types fired on every mutation

Configuration

# skills runtime config.

# State scopes used to persist the two registries. Changing these at
# runtime is supported but orphans prior entries; treat them as
# deployment-time constants in practice.
scopes:
  skills: skills
  prompts: prompts

# Default timeout for state::* and sub-skill function triggers (ms).
state_timeout_ms: 10000

# Glob patterns for filesystem-backed skills / prompts. Resolved
# relative to the directory of this config file. See
# "Filesystem-backed skills and prompts" below.
skills:
  - my-custom-skills-folder/**/*.md
prompts:
  - my-custom-prompts-folder/**/*.md

Filesystem-backed skills and prompts

The two top-level skills: and prompts: arrays in the config are glob patterns that point at markdown files on disk. Every match becomes a registry entry whose body is re-read from the file on every resolve — the file system is the single source of truth. Nothing is ever mirrored into iii-state.

This is useful for shipping per-project documentation alongside an iii deployment without writing a worker, and for layering a team's canonical prompt library on top of the runtime registries.

skills:
  - my-custom-skills-folder/**/*.md
  - shared/skills/**/*.md
prompts:
  - my-custom-prompts-folder/**/*.md

How globs are resolved

Patterns are interpreted by the glob crate (*, ?, [abc], {a,b}, ** for recursive directory traversal). Relative paths resolve against the directory of the config file, not the worker's CWD; this keeps configurations portable when the config moves with the deployment.

Glob expansion happens fresh on every list / index / read call, so files added or removed between worker boots are reflected without a restart. Body content is also re-read on every iii://{id} resolve — there is no in-memory body cache.

Skill ID derivation

For each match, the worker derives an id by stripping:

  1. The static prefix of the glob (everything up to the first wildcard, truncated back to the last /).
  2. The trailing .md extension.
Glob Match Derived id
my-skills/**/*.md my-skills/foo.md foo
my-skills/**/*.md my-skills/foo/bar.md foo/bar
my-skills/**/*.md my-skills/foo/bar/baz.md foo/bar/baz
*.md top.md top
docs/team-a.md docs/team-a.md team-a

Each segment must satisfy the same [a-z0-9_-]{1,64} rule that state-backed registrations enforce. Files whose derived id contains uppercase, spaces, or other invalid characters are skipped at boot with a tracing::warn!.

The first H1 of the file is the title, and the first non-heading paragraph is the description — same rule the auto-rendered iii://skills index uses for state-backed bodies. No frontmatter is required for skills (you can keep your existing markdown layout).

Prompt frontmatter

Filesystem-backed prompts must start with a YAML frontmatter block declaring at least description. name is optional and overrides the file-basename-derived default:

---
name: open-pr
description: Create a GitHub pull request with a repository-standard title and a body that follows `.github/pull_request_template.md`. Use when the user wants to open a PR, create a pull request, or submit branch changes for review.
---

The body is returned verbatim as a single user-text message on
`prompts::mcp-get`. No `{placeholder}` substitution and no
caller-supplied `arguments` — fs prompts are static templates.

A prompt without frontmatter, with malformed YAML, or without a non-empty description is skipped at boot with a warning. Names must follow the same [a-z0-9_-]{1,64} rule as state-backed prompts.

Collision policy (state always wins)

If a filesystem entry's id (or prompt name) is already present in the matching state-backed scope, the filesystem entry is silently shadowed: it does not appear in skills::list / prompts::list, it is not rendered in the iii://skills index, and iii://{id} resolves to the state row.

Collisions are recorded as one of the boot warnings emitted by log_fs_health so the operator can resolve them by either unregistering the state row or moving / renaming the file.

Where filesystem entries appear

Surface What you see
skills::list All entries, each tagged origin: "state" or origin: "fs".
prompts::list All entries, each tagged origin: "state" or origin: "fs".
iii://skills (auto-rendered index) Existing state-backed bullets followed by a new ## Custom skills H2 section listing fs entries (only when at least one fs skill is loaded).
iii://{id} (resource read) Direct body lookup. State row wins on any collision; on miss the fs entry is read fresh from disk.
prompts/get (MCP) Static body returned as a single user-text message; caller-supplied arguments are ignored with a debug log.

Boot diagnostics

log_fs_health runs once at boot and logs:

  • One info! line per loaded fs skill / prompt (id + absolute path).
  • One warn! line per skipped file (path + reason: invalid id, missing frontmatter, intra-fs duplicate, collision with state, …).
  • A single summary line with totals.

These are the canonical place to debug a misconfigured glob.


Skills (markdown orientation docs)

A skill is a markdown document explaining when and why to use your worker's tools. Skills don't replicate the JSON schemas of your functions — clients get those from the engine's tool listing. Skills tell the LLM which tool for which job.

Register one with skills::register:

use iii_sdk::{register_worker, InitOptions, TriggerRequest};
use serde_json::json;

let iii = register_worker("ws://localhost:49134", InitOptions::default());

iii.trigger(TriggerRequest {
    function_id: "skills::register".into(),
    payload: json!({
        "id": "myworker",
        "skill": include_str!("../docs/skill.md"),
    }),
    action: None,
    timeout_ms: Some(5_000),
}).await?;

Validation rules (rejected at registration time):

  • id: 1+ path segments separated by /. Each segment uses lowercase ASCII letters, digits, - and _; max 64 chars per segment. Total id length capped at 1024 chars.
  • The literal fn is reserved as the first segment of any id (used as the section-URI marker — see Section URIs). fn may appear at deeper segments freely (docs/fn-reference is fine).
  • skill: non-empty; max 256 KiB.

Re-registering with the same id overwrites the body and refreshes the registered_at timestamp. Workers MUST re-register on every boot so any updated content shipped with the worker lands in the registry. Doing this unconditionally also keeps you robust to state being wiped during local development.

URI scheme

URI Returns
iii://skills Auto-rendered markdown index of every registered skill (entry point).
iii://{id} The body registered at that id (single segment).
iii://{a}/{b}/.../{leaf} The body registered at the slashed path. Any depth. First segment must NOT equal fn.
iii://fn/{a}/{b}/.../{leaf} Trigger function a::b::...::leaf with {} and serve its output. Each / after fn/ becomes ::.

The first-segment fn literal flips the URI from "skill body lookup" to "function trigger". Anything else as the first segment is treated as a slashed path of skill ids and resolved against the skills state scope as a single key. This keeps the scheme unambiguous at arbitrary depth: iii://resend/email/send is always a stored markdown body, while iii://fn/resend/email/send is always a call to resend::email::send.

The auto-rendered iii://skills index uses the first H1 of each skill as the link title and the first non-heading paragraph as the description (truncated at 140 chars). Nested entries are indented by 2 * depth spaces so the index reads as a tree. Lead each registered body with a # {title} and a short summary paragraph and the index reads cleanly without further work.

Modeling a deep skill tree

Top-level skill bodies should stay small — a router that links to deeper, more granular content. The agent only loads iii://yourworker initially; everything else is fetched lazily through skill::fetch (see below). Token cost grows with how deep the agent actually drills, not with how big your worker's docs are.

Each level is an independent state row. Parents don't have to exist first (orphans are allowed but read awkwardly in the index). A re-register at the same id overwrites that row only — siblings and children are untouched.

Worked example: a small resend worker with two depths of nested skills plus one section URI that points at a function.

use iii_sdk::{RegisterFunction, TriggerRequest};
use serde_json::{json, Value};

// 1. Top-level router (small body — links into children).
iii.trigger(TriggerRequest {
    function_id: "skills::register".into(),
    payload: json!({
        "id": "resend",
        "skill": "# resend\n\nEmail provider integration.\n\n\
                  - [`email`](iii://resend/email) — sending and tracking\n\
                  - [`contacts`](iii://resend/contacts) — audience management\n\
                  \n\
                  Live status check: \
                  [`health`](iii://fn/resend/health)\n",
    }),
    action: None,
    timeout_ms: Some(5_000),
}).await?;

// 2. Mid-level group (also a router into its own children).
iii.trigger(TriggerRequest {
    function_id: "skills::register".into(),
    payload: json!({
        "id": "resend/email",
        "skill": "# resend/email\n\nEmail flows.\n\n\
                  - [`send`](iii://resend/email/send) — outbound\n\
                  - [`track`](iii://resend/email/track) — webhooks + status\n",
    }),
    action: None,
    timeout_ms: Some(5_000),
}).await?;

// 3. Leaf body — the actual content the agent will read once it
// decides to drill in. Loaded on demand via skill::fetch.
iii.trigger(TriggerRequest {
    function_id: "skills::register".into(),
    payload: json!({
        "id": "resend/email/send",
        "skill": include_str!("../docs/resend-email-send.md"),
    }),
    action: None,
    timeout_ms: Some(5_000),
}).await?;

// 4. A section URI: live function output instead of a static body.
// `iii://fn/resend/health` triggers `resend::health` with {} and
// returns its output. Useful for status, dynamic catalogues, etc.
iii.register_function(
    RegisterFunction::new("resend::health", |_input: Value| {
        Ok::<_, String>(json!({
            "content": "# Resend health\n\nAPI: ok. Quota: 73% remaining."
        }))
    })
    .description("Live operational health for the resend integration."),
);

Once registered, an agent reading iii://resend sees the small router. If it needs send semantics, it asks skill::fetch for iii://resend/email/send (one round trip, ~200 lines of markdown instead of the worker's full 2000-line documentation).

Section URIs (function-backed content)

Section URIs trigger an iii function with {} and serve its output as resource content. The URI shape is iii://fn/{a}/{b}/... and the mapping to a function id is mechanical: every / after fn/ becomes ::. This means agents and humans can construct a URI from any function id by string-replace alone.

URI Function id triggered
iii://fn/foo foo
iii://fn/scope/foo scope::foo
iii://fn/resend/email/send resend::email::send
iii://fn/a/b/c/d a::b::c::d

A function backing a section URI should return one of:

  • a String → served as text/markdown.
  • { "content": "..." } → the content field is served as text/markdown.
  • anything else → pretty-printed JSON, served as application/json.

The recursion guard blocks the internal namespaces (engine::*, state::*, mcp::*, skills::*, prompts::*, iii::*, iii.*, a2a::*) at read time so a crafted iii://fn/state/set can't tunnel into infra.

Function ids that contain . (e.g. the engine's iii.on_foo lifecycle handlers) are NOT reachable through this shape. That's intentional — those handlers are triggered on engine events, not by agents.

Fetching skills on demand

Top-level skill bodies are designed to stay small (a router that points at sub-skills) so they don't burn the LLM's context budget on content the agent might never need. Consumers resolve those iii:// links lazily via the skill::fetch tool — a thin batched wrapper over the same resolver that backs skills::resources-read.

Field Type Description
uri string A single iii:// URI to read.
uris string[] Multiple iii:// URIs to read and concatenate. Wins when both are provided.

Each URI is wrapped as # {uri}\n\n{body} and sections are joined with \n\n---\n\n into one markdown document, so the agent can pull several sub-skills in one round trip:

{ "uris": ["iii://resend/email/send", "iii://resend/email/track"] }

This composes naturally with the multi-level body shape: the agent loads the top-level router once, then batches fetches for the deeper bodies it actually needs (and the iii://fn/... section URIs of the functions it expects to call).

Two registrations, one handler:

  • skill::fetch — public alias on a non-hidden namespace. MCP clients see it as the tool skill__fetch, which is what the description tells agents to call when they encounter iii:// links in skill instructions. This is the id agents should use.
  • skills::fetch_skill — canonical id, colocated with the rest of the registry. Hidden from MCP tools/list because the bridge hard-floors every skills:: prefix; sibling workers can still call it directly via iii.trigger.

Validation rules (rejected before any resource is touched):

  • At least one of uri / uris must be present and non-blank after trim.
  • Every URI must start with iii://. Other schemes are rejected with a clear error so an agent can correct the call rather than silently fetching nothing.

Prompts (slash commands)

A prompt is a parametric template the user invokes from the client (e.g. typing /send-email). The template is rendered by your handler function with the user-supplied arguments.

Registration is two steps:

  1. Register a normal handler function. No special opt-in flag is needed — skills dispatches the handler directly through iii.trigger when the prompt is invoked.
  2. Register the prompt itself, pointing at that function.
use iii_sdk::{RegisterFunction, TriggerRequest};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;

#[derive(Deserialize, JsonSchema)]
struct SendEmailArgs {
    to: String,
    subject: String,
}

#[derive(Serialize, JsonSchema)]
struct PromptOutput {
    content: String,
}

// Step 1: handler renders the prompt
iii.register_function(
    RegisterFunction::new("myworker::send_email_prompt", |args: SendEmailArgs| {
        Ok::<_, String>(PromptOutput {
            content: format!(
                "Compose an email to {} with the subject \"{}\". Be concise and friendly.",
                args.to, args.subject
            ),
        })
    })
    .description("Render the send-email slash-command body."),
);

// Step 2: register the slash-command
iii.trigger(TriggerRequest {
    function_id: "prompts::register".into(),
    payload: json!({
        "name": "send-email",
        "description": "Compose and send an email",
        "arguments": [
            { "name": "to",      "description": "Recipient address", "required": true },
            { "name": "subject", "description": "Subject line",      "required": true }
        ],
        "function_id": "myworker::send_email_prompt"
    }),
    action: None,
    timeout_ms: Some(5_000),
}).await?;

Validation rules (rejected at registration time):

  • name: lowercase ASCII letters, digits, - and _ only; max 64 chars (no ::, no /, no whitespace)
  • description: non-empty after trim
  • function_id: non-empty after trim
  • arguments[].name: non-empty, no duplicates within the list

Argument schema gotcha

The arguments field is what the client sees in its argument-picker UI. It does not auto-validate at runtime. The handler is responsible for validating its own input — treat the schema as a contract you uphold inside the function.

Output normalization

The handler can return any of:

  • String → wrapped as a single user-text message
  • { "content": "..." } → wrapped as a single user-text message
  • { "messages": [ ... ] } → passed through unchanged (full control; use this for multi-turn templates or assistant-prefilled responses)
  • anything else → an error returned to the client

Lifecycle & boot-time handshake

Skills and prompts are stored in iii-state under scopes skills and prompts (configurable). Both registries are durable and survive restarts of either skills or your worker.

Workers MUST re-register on every boot. skills itself can be absent or come up after your worker, so treat the registration as best-effort with capped exponential backoff:

use std::sync::Arc;
use std::time::{Duration, Instant};
use iii_sdk::{TriggerRequest, III};
use serde_json::json;

fn register_with_iii_skills(iii: Arc<III>) {
    tokio::spawn(async move {
        let mut backoff = Duration::from_secs(5);
        let started = Instant::now();
        loop {
            let result = iii.trigger(TriggerRequest {
                function_id: "skills::register".into(),
                payload: json!({
                    "id": "myworker",
                    "skill": include_str!("../docs/skill.md"),
                }),
                action: None,
                timeout_ms: Some(5_000),
            }).await;
            if result.is_ok() {
                tracing::info!("registered myworker skill");
                return;
            }
            if started.elapsed() > Duration::from_secs(180) {
                tracing::warn!("skills handshake gave up; install / start it and restart this worker");
                return;
            }
            tokio::time::sleep(backoff).await;
            backoff = (backoff * 2).min(Duration::from_secs(60));
        }
    });
}

On graceful shutdown, optionally call skills::unregister and prompts::unregister to clean up. Crashes leave entries in state indefinitely; an operator can list them with skills::list / prompts::list (which expose registered_at) and remove dead ones manually.

Mutations of either scope fire the skills::on-change / prompts::on-change custom trigger types automatically. Any worker can subscribe and react to registrations without polling.

Subscribing to registry changes

Workers that need to react to registrations (a dashboard, a metrics sink, a sibling protocol bridge) subscribe to the custom trigger types:

use iii_sdk::RegisterTriggerInput;
use serde_json::json;

iii.register_trigger(RegisterTriggerInput {
    trigger_type: "skills::on-change".into(),
    function_id: "myworker::on_skill_change".into(),
    config: json!({}),
    metadata: None,
})?;

Payload sent to each subscriber:

Trigger type Payload
skills::on-change { "op": "register" | "unregister", "id": "" }
prompts::on-change { "op": "register" | "unregister", "name": "" }

Dispatches are fire-and-forget (Void), so the write path on skills::register / prompts::register doesn't block on downstream latency. Idempotent unregisters that found nothing to delete don't fire.

Full multi-language example

Self-contained worker that registers one skill, one sub-skill function, and one slash-command prompt. Boots, registers everything, then sleeps until SIGINT.

Rust

use iii_sdk::{register_worker, InitOptions, RegisterFunction, TriggerRequest};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Default, Deserialize, JsonSchema)]
struct GreetArgs {
    name: String,
}

#[derive(Serialize, JsonSchema)]
struct SkillContent {
    content: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let iii = register_worker("ws://localhost:49134", InitOptions::default());

    // 1. Sub-skill function: returns extended docs as markdown.
    iii.register_function(
        RegisterFunction::new("demo::guide", |_input: Value| {
            Ok::<_, String>(SkillContent {
                content: "# Demo guide\n\nThe long-form docs for the demo worker."
                    .into(),
            })
        })
        .description("Detailed guide for the demo worker."),
    );

    // 2. Prompt handler: turns slash-command args into a templated message.
    iii.register_function(
        RegisterFunction::new("demo::greet_prompt", |input: GreetArgs| {
            Ok::<_, String>(SkillContent {
                content: format!("Greet {} warmly.", input.name),
            })
        })
        .description("Render the greet prompt."),
    );

    // Wire the registries. Always skills::register / prompts::register
    // at startup so the entries refresh after any worker upgrade.
    iii.trigger(TriggerRequest {
        function_id: "skills::register".into(),
        payload: json!({
            "id": "demo",
            "skill": "# demo\n\n\
                      A demo worker.\n\n\
                      See [`guide`](iii://demo/demo::guide) for the long version.\n"
        }),
        action: None,
        timeout_ms: Some(5_000),
    }).await?;

    iii.trigger(TriggerRequest {
        function_id: "prompts::register".into(),
        payload: json!({
            "name": "greet",
            "description": "Compose a greeting.",
            "arguments": [
                { "name": "name", "description": "Who to greet", "required": true }
            ],
            "function_id": "demo::greet_prompt"
        }),
        action: None,
        timeout_ms: Some(5_000),
    }).await?;

    tokio::signal::ctrl_c().await?;
    Ok(())
}

Node

import { registerWorker } from 'iii-sdk'

const iii = registerWorker('ws://localhost:49134')

iii.registerFunction({ id: 'demo::guide' }, async () => ({
  content: '# Demo guide\n\nThe long-form docs for the demo worker.',
}))

iii.registerFunction({ id: 'demo::greet_prompt' }, async ({ name = 'world' }) => ({
  content: `Greet ${name} warmly.`,
}))

await iii.trigger({
  function_id: 'skills::register',
  payload: {
    id: 'demo',
    skill: '# demo\n\nA demo worker.\n\nSee [`guide`](iii://demo/demo::guide) for the long version.\n',
  },
})

await iii.trigger({
  function_id: 'prompts::register',
  payload: {
    name: 'greet',
    description: 'Compose a greeting.',
    arguments: [{ name: 'name', description: 'Who to greet', required: true }],
    function_id: 'demo::greet_prompt',
  },
})

Python

from iii_sdk import register_worker

iii = register_worker('ws://localhost:49134')

@iii.register_function('demo::guide')
async def guide(_input):
    return {'content': '# Demo guide\n\nThe long-form docs for the demo worker.'}

@iii.register_function('demo::greet_prompt')
async def greet_prompt(input):
    name = input.get('name', 'world')
    return {'content': f'Greet {name} warmly.'}

await iii.trigger(
    function_id='skills::register',
    payload={
        'id': 'demo',
        'skill': '# demo\n\nA demo worker.\n\nSee [`guide`](iii://demo/demo::guide) for the long version.\n',
    },
)

await iii.trigger(
    function_id='prompts::register',
    payload={
        'name': 'greet',
        'description': 'Compose a greeting.',
        'arguments': [{'name': 'name', 'description': 'Who to greet', 'required': True}],
        'function_id': 'demo::greet_prompt',
    },
)

Functions

Thirteen functions across the two registries plus one fetch alias. The seven public CRUD entries (six registry CRUD + the public fetch alias) are callable by any worker over iii.trigger; the alias is also surfaced as an MCP tool. The remaining six entries are internal-RPC reserved for protocol-bridge workers; they never appear in tools/list because their ids start with the skills:: / prompts:: hard-floor prefixes.

Function ID Description
skills::register Store a markdown skill body keyed by id.
skills::unregister Delete a skill by id. Idempotent.
skills::list Metadata-only listing, sorted by id.
skills::resources-list Internal: enumerate registered skills as resource entries.
skills::resources-read Internal: resolve an iii:// URI to its content.
skills::resources-templates Internal: declare the iii://{id} URI templates.
skills::fetch_skill Internal: batched read across one or more iii:// URIs. Hidden from MCP because the id starts with skills::.
skill::fetch Public alias of skills::fetch_skill on a non-hidden namespace. Surfaces as the MCP tool skill__fetch so agents can resolve iii:// links on demand.
prompts::register Store a slash-command prompt definition.
prompts::unregister Delete a prompt by name. Idempotent.
prompts::list Metadata-only listing, sorted by name.
prompts::mcp-list Internal: enumerate registered prompts with argument schemas.
prompts::mcp-get Internal: dispatch a registered prompt handler with caller-supplied arguments.

Custom trigger types

Trigger type Fires when Payload to subscribers
skills::on-change After every mutation of the skills registry { "op": "register" | "unregister", "id": "" }
prompts::on-change After every mutation of the prompts registry { "op": "register" | "unregister", "name": "" }

Local development & testing

Run from source

cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yaml

Tests

# Fast, offline — exercises the pure helpers (markdown / URI / validators)
# without needing an iii engine.
cargo test --test bdd -- --tags @pure

# Full suite — requires an iii engine on ws://127.0.0.1:49134
# (or III_ENGINE_WS_URL). Runs the full BDD scenario set covering
# every function and every validation rule.
cargo test

# One feature group at a time. Available tags:
#   @pure  @markdown
#   @engine  @skills_register  @skills_resources  @skills_fetch  @skills_nested
#   @prompts_register  @prompts_get  @notifications  @mcp_bridge
cargo test --test bdd -- --tags @skills_nested

The BDD harness lives under tests/. Feature files mirror the modules in src/functions/. Step definitions under tests/steps/ drive each feature through the same iii.trigger path the production binary uses.


Migration (v0.1.x → v0.2.0)

The URI scheme changed in two breaking ways. Workers that registered skills against 0.1.x need to update before upgrading.

1. Function-backed URIs use fn/ as a fixed prefix. The legacy two-segment form iii://{anything}/{worker::function_id} is gone. Rewrite each link by prefixing iii://fn/ and replacing every :: with /:

Old New
iii://anything/myworker::echo iii://fn/myworker/echo
iii://brain/brain::summarize iii://fn/brain/summarize
iii://x/state::get iii://fn/state/get (still blocked by the recursion guard)

The legacy two-segment form now resolves to a state-backed sub-skill lookup, so it returns "Skill not found" instead of triggering the function. There is no silent fallback.

2. Multi-segment ids are now stored skills. iii://resend/email and similar are state-backed bodies you can register. There is no depth cap (per-segment cap stays at 64 chars; total id capped at 1024 chars). The fn literal is reserved as the first segment of any registered id; deeper occurrences (docs/fn-reference) are fine.

Workers MUST update their bundled markdown bodies (replace any iii://x/worker::fn in skill text with iii://fn/worker/fn) and re-register at boot. The skills::on-change trigger fires on every re-registration, so subscribers stay in sync automatically.