skills
v0.1.5Agentic 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
full markdown
/workers/skills.md?version=0.1.5. paste it into an llm prompt or pipe it through curl from a worker.install
configuration
- scopes:
prompts: prompts
skills: skills
state_timeout_ms: 10000dependencies
readme
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
- Install
- Quickstart: publish a skill and a slash-command
- Configuration
- Functions
- Custom trigger types
- Local development & testing
Install
iii worker add skillsiii 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 |
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: lowercase ASCII letters, digits,-and_only; max 64 charsskill: 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://{your_id} |
The body you registered |
iii://{your_id}/{your_function_id} |
Triggers your_function_id with {} and returns its output as content |
The third shape lets you split a skill across multiple files. Register
your top-level skill at iii://{your_id}, then reference sub-content
inside it as markdown links to functions you also registered. Any
function the engine knows about is reachable this way; there's no
opt-in flag. A short reserved-prefix list (engine internals, state
plumbing, this worker's own admin functions) is rejected at read time
to keep the resolver from tunneling back into infra.
A function backing iii://{id}/{fn} should return one of:
- a string → served as
text/markdown { "content": "..." }→ thecontentfield is served astext/markdown- anything else → pretty-printed JSON, served as
application/json
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). Lead with a # {title} and a
short summary paragraph and the index reads cleanly without further
work.
Worked example: skill with sub-skills
use iii_sdk::{RegisterFunction, TriggerRequest};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Serialize, JsonSchema)]
struct SkillContent {
content: String,
}
iii.trigger(TriggerRequest {
function_id: "skills::register".into(),
payload: json!({
"id": "brain",
"skill": "# brain\n\nHelps build UIs with Tailwind.\n\n\
See [`summarize`](iii://brain/brain::summarize) \
for the catalogue.\n",
}),
action: None,
timeout_ms: Some(5_000),
}).await?;
iii.register_function(
RegisterFunction::new("brain::summarize", |_input: Value| {
Ok::<_, String>(SkillContent {
content: include_str!("../docs/summarize.md").to_string(),
})
})
.description("Index of UI design guidelines."),
);Now any consumer can read iii://brain for the orientation and click
through to iii://brain/brain::summarize for the catalogue. The
resource resolver invokes the sub-skill function directly through
iii.trigger, so it doesn't need any extra opt-in flag.
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:
- Register a normal handler function. No special opt-in flag is
needed —
skillsdispatches the handler directly throughiii.triggerwhen the prompt is invoked. - 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 trimfunction_id: non-empty after trimarguments[].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',
},
)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: 10000CLI flags:
--config <PATH> Path to config.yaml [default: ./config.yaml]
--url <URL> WebSocket URL of the iii engine [default: ws://127.0.0.1:49134]
--manifest Output the module manifest as JSON and exit
-h, --help Print helpIf the config file is missing or malformed the worker logs a warning and falls back to the defaults — boot is never blocked by a bad config path.
Functions
Eleven functions across the two registries. The six public CRUD
entries are callable by any worker over iii.trigger. The five
internal-RPC entries are reserved for protocol-bridge workers that
serve the registries to external clients; they never surface as agent
tools.
| 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. |
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.yamlTests
# 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
# @prompts_register @prompts_get @notifications
cargo test --test bdd -- --tags @skills_resourcesThe 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.
api reference (json)
{
"functions": [
{
"description": "Internal: returns the MCP prompts/list envelope (full arguments schema for each registered prompt).",
"metadata": {},
"name": "prompts::mcp-list",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmptyInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AnyValue"
}
},
{
"description": "Internal: resolves an iii:// URI and returns the MCP resources/read contents envelope.",
"metadata": {},
"name": "skills::resources-read",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"uri": {
"type": "string"
}
},
"required": [
"uri"
],
"title": "ReadResourceInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AnyValue"
}
},
{
"description": "Internal: returns the MCP resources/templates/list envelope.",
"metadata": {},
"name": "skills::resources-templates",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmptyInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AnyValue"
}
},
{
"description": "Register a slash-command prompt; clients call prompts/get to render it.",
"metadata": {},
"name": "prompts::register",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PromptArgument": {
"properties": {
"description": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"required": {
"default": false,
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
}
},
"properties": {
"arguments": {
"default": [],
"items": {
"$ref": "#/definitions/PromptArgument"
},
"type": "array"
},
"description": {
"description": "Free-text description shown in the client's prompt picker.",
"type": "string"
},
"function_id": {
"description": "Handler function called on prompts/get with the supplied arguments.",
"type": "string"
},
"name": {
"description": "Unique prompt name (lowercase ASCII, kebab/underscore, max 64 chars).",
"type": "string"
}
},
"required": [
"description",
"function_id",
"name"
],
"title": "RegisterPromptInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string"
},
"registered_at": {
"type": "string"
}
},
"required": [
"name",
"registered_at"
],
"title": "RegisterPromptOutput",
"type": "object"
}
},
{
"description": "Register a markdown skill so it appears as iii://{id} in resources/list.",
"metadata": {},
"name": "skills::register",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"description": "Unique skill id (lowercase ASCII, kebab/underscore, max 64 chars).",
"type": "string"
},
"skill": {
"description": "Markdown body served at iii://{id}.",
"type": "string"
}
},
"required": [
"id",
"skill"
],
"title": "RegisterSkillInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string"
},
"registered_at": {
"type": "string"
}
},
"required": [
"id",
"registered_at"
],
"title": "RegisterSkillOutput",
"type": "object"
}
},
{
"description": "Remove a registered prompt by name.",
"metadata": {},
"name": "prompts::unregister",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
],
"title": "UnregisterPromptInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"name": {
"type": "string"
},
"removed": {
"type": "boolean"
}
},
"required": [
"name",
"removed"
],
"title": "UnregisterPromptOutput",
"type": "object"
}
},
{
"description": "List registered prompts (name, function_id, arg count, registered_at).",
"metadata": {},
"name": "prompts::list",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ListPromptsInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PromptEntry": {
"properties": {
"arguments": {
"format": "uint",
"minimum": 0,
"type": "integer"
},
"function_id": {
"type": "string"
},
"name": {
"type": "string"
},
"registered_at": {
"type": "string"
}
},
"required": [
"arguments",
"function_id",
"name",
"registered_at"
],
"type": "object"
}
},
"properties": {
"prompts": {
"items": {
"$ref": "#/definitions/PromptEntry"
},
"type": "array"
}
},
"required": [
"prompts"
],
"title": "ListPromptsOutput",
"type": "object"
}
},
{
"description": "List registered skills (id, body length, registered_at) without bodies.",
"metadata": {},
"name": "skills::list",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ListSkillsInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"SkillEntry": {
"properties": {
"bytes": {
"format": "uint",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"registered_at": {
"type": "string"
}
},
"required": [
"bytes",
"id",
"registered_at"
],
"type": "object"
}
},
"properties": {
"skills": {
"items": {
"$ref": "#/definitions/SkillEntry"
},
"type": "array"
}
},
"required": [
"skills"
],
"title": "ListSkillsOutput",
"type": "object"
}
},
{
"description": "Internal: returns the MCP resources/list envelope with the iii://skills index + one iii://{id} entry per registered skill.",
"metadata": {},
"name": "skills::resources-list",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmptyInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AnyValue"
}
},
{
"description": "Internal: dispatches a registered prompt handler and normalizes the result for MCP prompts/get.",
"metadata": {},
"name": "prompts::mcp-get",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"arguments": {
"default": null
},
"name": {
"type": "string"
}
},
"required": [
"name"
],
"title": "PromptGetInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AnyValue"
}
},
{
"description": "Remove a registered skill by id.",
"metadata": {},
"name": "skills::unregister",
"request_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
],
"title": "UnregisterSkillInput",
"type": "object"
},
"response_schema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string"
},
"removed": {
"type": "boolean"
}
},
"required": [
"id",
"removed"
],
"title": "UnregisterSkillOutput",
"type": "object"
}
}
],
"triggers": [
{
"description": "Fires after any mutation of the skills registry (register / unregister).",
"invocation_schema": {},
"metadata": {},
"name": "skills::on-change",
"return_schema": {}
},
{
"description": "Fires after any mutation of the prompts registry (register / unregister).",
"invocation_schema": {},
"metadata": {},
"name": "prompts::on-change",
"return_schema": {}
}
]
}