Iii logo

Iii

OrganizationPopular
iii-hq
iii

How iii works and the iii-sdk surface for authoring workers, triggers, and functions. Teaches the ordered way to gain a capability before writing code — (1) check functions already registered in the engine, (2) search the public registry via iii-directory, (3) build a worker. Single self-contained skill — meant for system-prompt injection; do not re-fetch.

Overview

Publisheriii-hq
Repositoryiii
Skill nameiii
Stars
18.8K
Forks
1.3K
Bundled files
Instructions only
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by iii-hq on GitHub. Read the source before you install it.

Installation

Install the Iii AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/iii-hq/iii/tree/main/engine/src/workers/engine_fn/skills
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/iii-hq/iii.git /tmp/iii
mkdir -p .claude/skills
cp -r /tmp/iii/engine/src/workers/engine_fn/skills .claude/skills/iii
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Iii in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Iii on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Iii is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

iii

iii is a language-agnostic runtime where services, agents, and tools are composed of the same things: workers, triggers, and functions. One engine process (default port 49134) holds a live registry of every connected worker, every function those workers expose, and every trigger bound to them. Workers are independent OS processes that open a WebSocket to the engine and register Functions (service::name handlers) and Triggers (the events that invoke those Functions). There is no direct worker-to-worker traffic — every call routes through the engine, which makes the language, runtime, and physical location of any worker invisible to its callers.

When to Use

Use this skill to discover live iii capabilities, call functions, author SDK workers, bind trigger types, or operate project workers through Compose.

You extend yourself by writing iii workers. A few lines get you on the bus:

ts
import { registerWorker } from 'iii-sdk'

const iii = registerWorker(process.env.III_ENGINE_URL!, { workerName: 'demo' })

iii.registerFunction('demo::add', async (payload: { a: number; b: number }) => {
  return { c: payload.a + payload.b }
})

The instant the handshake completes, demo::add is callable from any worker (and the harness itself) via iii.trigger({ function_id: 'demo::add', payload: { a: 2, b: 3 } }). No restart, no registration with the harness — the engine routes it automatically.

The four primitives

PrimitiveWhat it isOwned by
EngineOne coordinator process. Routes every invocation.The operator
WorkerA process that opens a WebSocket to the engine.Anyone who writes one
FunctionA named handler inside a worker, id service::name. Stable across worker restarts.The registering worker
TriggerA (type, config, function_id) triple. Causes a function to run when an event fires.A worker (the type-publisher) + a caller (the binding)

Three consequences worth internalising:

  1. No worker-to-worker traffic. Every call is worker → engine → worker. Workers never address each other directly. Location and language are invisible.
  2. No restart coordination. Restarting a worker is invisible to callers as long as it re-registers the same function ids. Two workers registering the same function id = automatic load-balance.
  3. No polling unless you opt in. Triggers are the engine's push channel. The engine fans events out to bound functions when the underlying source fires.

The function id is the only contract between any two workers.

Every edge to the engine is a WebSocket. A trigger-type provider terminates some native protocol — an inbound request, a timer, a queue message — and translates it into engine traffic.

Need a capability? Discover before you build — in this order

The most common harness mistake is reimplementing something that already exists, or hardwiring one worker out of habit. Work the steps in order; stop at the first that satisfies the need.

1. Look at what is already registered in the engine. The capability may be one call away.

jsonc
// engine::functions::list   — every function on this engine, across all workers.
//   Filter with { prefix: 'svc::' } or { search: 'resize' }.
// engine::workers::list      — every connected worker.

If a registered function fits, just call it: iii.trigger({ function_id, payload }).

2. Search the public registry. If nothing registered fits, look for a worker to install. This goes through the iii-directory worker:

jsonc
// directory::registry::workers::list { search: 'image resize' }
//   → published workers matching the query.
// directory::registry::workers::info { name: '<worker>' }
//   → that worker's README, config keys, API reference, and skills.

When one fits, add it through the project's Compose daemon:

bash
iii trigger -n <compose-namespace> compose::add worker=<worker>

iii-directory is itself a registry worker, so confirm it is connected before calling directory::*:

jsonc
// engine::functions::list { prefix: 'directory::' }
//   → empty? add it to worker-compose.yaml through compose::add first.

3. Build a worker. Only when steps 1 and 2 both come up empty. Author it with the SDK (below), then deploy it. Discover the deployment/runtime surface the same way as any other capability — directory::registry::workers::list / ::info and its skill — rather than assuming a worker name. Add local code as a path:// container in worker-compose.yaml; relative paths resolve on the Compose daemon host.

Discover in order. Don't jump to a worker you remember; the registry may hold a better fit, and the right surface is whatever the live engine and registry report — not training-data recall.

The TypeScript SDK in brief

ts
import {
  registerWorker, // factory; opens the WS from your code's perspective synchronously
  TriggerAction, // .Void() | .Enqueue({ queue })
  InvocationError, // typed error thrown by iii.trigger()
} from 'iii-sdk'
import { Logger } from '@iii-dev/helpers/observability' // OTel-aware structured logger; falls back to console.*

const iii = registerWorker(process.env.III_ENGINE_URL!, {
  workerName: 'my-worker', // appears in engine::workers::list
  invocationTimeoutMs: 30_000,
  reconnectionConfig: { maxRetries: -1 }, // -1 = infinite (the default)
})

// Publish a function. Same handler shape regardless of how the invocation arrives.
const ref = iii.registerFunction('svc::do-thing', async (payload) => ({ ok: true }), {
  description, // JSON-Schema-shaped metadata
  request_format,
  response_format,
})
ref.id // 'svc::do-thing'
ref.unregister() // drop just this function, keep the WS open

// Invoke. Three modes — same method, different `action`.
await iii.trigger({ function_id, payload, timeoutMs })
await iii.trigger({ function_id, payload, action: TriggerAction.Void() })
await iii.trigger({ function_id, payload, action: TriggerAction.Enqueue({ queue }) })

// Bind a function to an event.
iii.registerTrigger({ type, function_id, config })

// Publish a new event source other workers can bind to.
iii.registerTriggerType({ id, description }, { registerTrigger, unregisterTrigger })

await iii.shutdown() // graceful close; engine evicts this worker's functions immediately

registerWorker(url, options?) opens the WebSocket synchronously from your code's perspective — there is no separate await connect(). The handle queues calls until the handshake lands.

Schemas in registerFunction (description, request_format, response_format) are JSON-Schema-shaped metadata — the engine does not validate payloads against them today. Declare them anyway: they surface in engine::functions::info, document the contract for the next caller, and reserve a slot for future runtime validation.

The three invocation modes

actionCaller blocks?Retries?ReturnsUse when
(omitted)yesnothe function's resultyou need the value to continue
TriggerAction.Void()nononullone-way notification, no result needed
TriggerAction.Enqueue({ queue })noyes{ messageReceiptId }slow/unreliable work; the queue handles retry + back-pressure

Errors

  • Throw inside the handler → propagates to the caller as InvocationError (carries code, function_id, stacktrace). Use for unexpected failures a retry might fix.
  • Return a structured value ({ ok: false, reason }) → the call succeeds; the caller branches on the shape. Use for expected failures (validation, not-found, business rules).

Rule of thumb: if a retry might succeed, throw; if it will fail the same way, return a value.

Lifecycle

  • iii.shutdown() flushes pending traffic and closes the WS; the engine evicts the worker's functions immediately and resolves in-flight calls to it as invocation_stopped.
  • ref.unregister() removes one registration (FunctionRef, Trigger, or TriggerTypeRef) without touching the others.
  • The SDK reconnects automatically with backoff and replays registrations verbatim — never re-register manually. Callers see invocation_stopped during the disconnect window; treat it as cancellation, not transient failure.

Triggers — discover the type, don't hardcode it

A trigger's type is a literal string published by some worker, and its config shape is defined by that worker. There is no fixed catalogue — discover what is available rather than assuming:

jsonc
// engine::triggers::list             → every trigger TYPE currently published (legal `type:` values).
// engine::triggers::info { id }       → that type's config + return JSON Schema, and its provider.
// directory::registry::workers::info  → the provider's README, when you need prose + examples.

Then bind, passing the literal type string and the config its schema requires:

ts
iii.registerTrigger({
  type: '<type from the list above>',
  function_id: 'svc::handler',
  config: {
    /* keys per the type's schema */
  },
})

Two cautions that apply to every trigger type:

  • registerTrigger succeeds at the engine even when the type provider is not connected or the config keys are wrong — the binding lands but never fires. Confirm the provider is up (engine::triggers::list shows the type) and copy config keys from the type's schema, not from memory.
  • The bound function receives whatever payload that trigger type delivers (its request schema in engine::triggers::info) and must return whatever shape that type expects. The handler contract is the trigger type's, not a generic one — check the schema before writing the handler.

Custom trigger types — the deepest leverage

registerTriggerType turns your worker's native event source (a webhook hit, a file change, a row update) into something the whole bus can react to without polling. Keep a { trigger_id → { function_id, config } } table in memory and walk it when the source fires:

ts
type FsWatchConfig = { path: string; recursive?: boolean }
const bindings = new Map<string, { function_id: string; config: FsWatchConfig }>()

iii.registerTriggerType<FsWatchConfig>(
  { id: 'fs::watch', description: 'Fires when a file under `path` changes.' },
  {
    async registerTrigger({ id, function_id, config }) {
      bindings.set(id, { function_id, config })
      startWatching(id, config)
    },
    async unregisterTrigger({ id }) {
      stopWatching(id)
      bindings.delete(id)
    },
  },
)

function onChange(triggerId: string, path: string) {
  const binding = bindings.get(triggerId)
  if (!binding) return
  iii.trigger({ function_id: binding.function_id, payload: { path }, action: TriggerAction.Void() })
}

From the caller's side, your custom type is indistinguishable from any built-in one.

Worker lifecycle — compose::*

Project workers live in worker-compose.yaml. The Compose daemon owns their install, startup, restart, update, and shutdown lifecycle:

bash
iii trigger -n dev compose::add worker=state
iii trigger -n dev compose::status file=worker-compose.yaml
iii trigger -n dev compose::logs file=worker-compose.yaml worker=state tail=100
iii trigger -n dev compose::restart file=worker-compose.yaml worker=state
iii trigger -n dev compose::update file=worker-compose.yaml worker=state
iii trigger -n dev compose::down file=worker-compose.yaml

iii worker and worker::* were removed. Use engine::workers::list for live registrations and compose::status for the supervisor's process state. Use compose::logs for raw worker stdout and stderr.

Discovery surface

CallReturns
engine::functions::listEvery function across all workers. Filter prefix / search.
engine::functions::info { function_id }One function's schemas, description, owning worker.
engine::workers::listEvery WS-connected worker.
engine::triggers::listEvery trigger TYPE published (legal type: values).
engine::triggers::info { id }One trigger type's config / return schema + provider.
engine::registered-triggers::listEvery trigger INSTANCE bound. Filter function_id / worker.
compose::statusDeclared containers, process state, PID, ownership, and last error.
compose::logsBounded worker stdout/stderr entries and continuation cursors.
compose::listProjects held by one Compose daemon.
directory::registry::workers::listWorkers published in the public registry. Filter search.
directory::registry::workers::info { name }A registry worker's README, config, API reference, and skills.
directory::skills::list / directory::skills::get { id }The markdown how-to a worker shipped — deeper than engine::functions::info.

engine::workers::list is the live registration view. compose::status is the process-supervisor view; consult both when diagnosing a declared container that did not register.

Trust runtime probes over introspection

engine::*::list reads can come back empty for blurred reasons: an older engine that lacks the surface, a store that lags live state, or genuinely nothing registered. Disambiguate with a runtime probe — call the function with iii.trigger(...). If the probe succeeds, the registration is live regardless of what *::list reported. Don't unbind or re-register on the strength of an empty list alone; you'll churn a working worker.

Anti-patterns

  • Polling instead of a trigger type. A function on a timer reading a queue / file / table every N seconds is almost always wrappable as a custom trigger type. See Custom trigger types.
  • Reinventing what exists. Run the discovery steps (engine::functions::list, then directory::registry::workers::list) before authoring anything.
  • Hardwiring a remembered worker. Pick the capability the live engine and registry surface, in order — not the one you reached for last time.
  • Side-channel state between workers. Don't have workers read each other's files or hit each other's endpoints; route every cross-worker call through iii.trigger, and use a shared-state worker (discover one in the registry) for shared key/value.
  • Catching the wrong error type. iii.trigger() throws InvocationError; catch that specifically or you lose code / function_id / stacktrace.
  • Trusting introspection over runtime probes. An empty *::list can mean lag, not absence — a successful iii.trigger() is the authoritative signal.

Boundaries

  • Do not put project workers in config.yaml; use worker-compose.yaml.
  • Do not call removed iii worker commands or worker::* functions.
  • Do not add registry packages of kind engine as Compose roots; the engine supplies them.
  • Treat function ids and trigger schemas from the live engine as authoritative.

Frequently asked questions

What does the Iii AI skill do?

How iii works and the iii-sdk surface for authoring workers, triggers, and functions. Teaches the ordered way to gain a capability before writing code — (1) check functions already registered in the engine, (2) search the public registry via iii-directory, (3) build a worker. Single self-contained skill — meant for system-prompt injection; do not re-fetch.

Why use Iii on TypingMind?

Because you install it once and use it with any model. Iii is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Iii in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/iii-hq/iii/tree/main/engine/src/workers/engine_fn/skills. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Iii?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Iii?

As many as you like. As long as a model supports skills, you can use Iii with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Iii AI skill free?

It is published on GitHub by iii-hq. Check the repository for licensing terms. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇