> ## Documentation Index
> Fetch the complete documentation index at: https://iii.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Workers

> Deploying and integrating workers into a iii project.

## How workers expand iii

Workers add capability to a iii system. Each one contributes functions and triggers the engine can
route to. This page covers deploying and wiring workers into a project.

Once connected, a worker exposes:

* Functions, callable by `function_id` from anywhere in the system (see
  [Using iii / Functions](../using-iii/functions)).
* Triggers it advertises, which other workers can bind their functions to (see
  [Using iii / Triggers](../using-iii/triggers)).

<Note>
  For the full SDK surface each Worker can use when interacting with iii, see the complete SDK
  reference by language: [Node](../reference/sdk-node), [Python](../reference/sdk-python),
  [Rust](../reference/sdk-rust), or [Browser](../reference/sdk-browser).
</Note>

## Create a new worker

A worker is any process that installs a iii SDK, connects to an engine, and registers functions or
triggers. Create a normal TypeScript, JavaScript, Python, Rust, or Go project using that language's
package tools, then add the corresponding iii SDK and an entrypoint such as `src/index.ts` or
`src/main.py`.

To let Compose start a local worker, add an `iii.worker.yaml` at the project root:

```yaml iii.worker.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
name: my-worker
description: One-line summary of what this worker does.
scripts:
  start: pnpm start
```

Declare the directory in `worker-compose.yaml`, or add it through a running daemon with
`iii trigger -n dev compose::add worker=./workers/my-worker`. See
[Using iii / Workers](../using-iii/workers#finding-workers) for the registry and local-path surface.

<Info title="Engine and SDK versions">
  The engine and SDK packages can have different patch versions within the same minor line. Keep the
  engine and SDKs on the same minor version, for example `0.11.x`, unless a release note says
  otherwise.
</Info>

## Connecting to the engine

A worker connects to the engine over WebSocket. The convention is to set the engine URL via the
`III_URL` environment variable, but it can also be passed explicitly to `register_worker`. The
connection string is the only coupling between a worker and the iii instance it joins, so the worker
process can be deployed anywhere reachable on the network.

<Note>
  This connects with full trust, appropriate for workers you run. For an untrusted worker (a browser
  client or a third party's), connect through the `iii-worker-manager` RBAC listener and gate it
  with an auth function instead. See [Untrusted workers and access
  control](../using-iii/workers#untrusted-workers-and-access-control) and the [iii-worker-manager
  worker page](https://workers.iii.dev/workers/iii-worker-manager).
</Note>

<Tabs>
  <Tab title="Node / TypeScript">
    ```typescript theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    import { registerWorker } from "iii-sdk";

    const url = process.env.III_URL;
    if (!url) throw new Error("III_URL must be set");
    const worker = registerWorker(url, {
      workerName: "my-worker",
      workerDescription: "One-line summary of what this worker does",
      namespace: "orders", // scopes this worker's registrations
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    import os
    from iii import register_worker, InitOptions

    worker = register_worker(
        os.environ.get("III_URL"),
        InitOptions(
            worker_name="my-worker",
            worker_description="One-line summary of what this worker does",
            namespace="orders",  # scopes this worker's registrations
        ),
    )
    ```
  </Tab>

  <Tab title="Rust">
    ```rust theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    use iii_sdk::runtime::WorkerMetadata;
    use iii_sdk::{InitOptions, register_worker};

    let url = std::env::var("III_URL").expect("III_URL must be set");
    let worker = register_worker(
        &url,
        InitOptions {
            metadata: Some(WorkerMetadata {
                name: "my-worker".into(),
                description: Some("One-line summary of what this worker does".into()),
                ..Default::default()
            }),
            namespace: Some("orders".into()), // scopes this worker's registrations
            ..Default::default()
        },
    );
    ```
  </Tab>
</Tabs>

`namespace` scopes everything this worker registers, so an identically-named worker or function id
can coexist in another namespace.

Leave the option out and the SDK reads the `III_NAMESPACE` environment variable itself, so these two
are equivalent when `III_NAMESPACE` is set in the worker's environment:

<Tabs>
  <Tab title="Node / TypeScript">
    ```typescript theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    registerWorker(url, { namespace: process.env.III_NAMESPACE });
    registerWorker(url);
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    register_worker(url, InitOptions(namespace=os.environ.get("III_NAMESPACE")))
    register_worker(url)
    ```
  </Tab>

  <Tab title="Rust">
    ```rust theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    register_worker(&url, InitOptions {
        namespace: std::env::var("III_NAMESPACE").ok(),
        ..Default::default()
    });
    register_worker(&url, InitOptions::default());
    ```
  </Tab>
</Tabs>

The SDK falls back to the `default` namespace when neither the option nor `III_NAMESPACE` gives it
one. The browser SDK has no environment to read, so it takes the option only.

Omit the option to serve many tenants from one worker package: set `III_NAMESPACE` per deployment,
and each deployment of the same image registers in its own namespace.

<Note>
  For calling across namespaces and handling a rejected registration, see [Use
  namespaces](../using-iii/namespaces).
</Note>

## Worker lifecycle

### States

Workers transition through a small set of states after connecting:
`connecting → connected → available / busy → disconnected`. `connecting` is the WebSocket handshake.
`connected` means the Worker has joined the Engine's registry. `available` and `busy` describe
whether the Worker is currently handling invocations. `disconnected` is the terminal state when the
WebSocket closes. The Engine tracks these transitions and surfaces them to other Workers and tooling
through its discovery functions, so the rest of the system can react.

### Inspecting the live registry

To see what's currently connected to the Engine, invoke one of the `engine::*::list` Functions to
get the current state of the registry. Each returns a list:

| Function                            | What it returns                                                      |
| ----------------------------------- | -------------------------------------------------------------------- |
| `engine::workers::list`             | Every connected Worker with metrics.                                 |
| `engine::functions::list`           | Every registered Function. Filterable by `include_internal`.         |
| `engine::triggers::list`            | Every advertised Trigger type with its config and call schemas.      |
| `engine::registered-triggers::list` | Every registered Trigger instance. Filterable by `include_internal`. |

<Accordion title="Example: list registry contents">
  <Tabs>
    <Tab title="Node / TypeScript">
      ```typescript theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      // engine::workers::list, pass { worker_id: "<uuid>" } to look up one worker
      const { workers } = await worker.trigger({
        function_id: "engine::workers::list",
        payload: {},
      });

      // engine::functions::list
      const { functions } = await worker.trigger({
        function_id: "engine::functions::list",
        payload: { include_internal: false },
      });

      // engine::triggers::list
      const { triggers } = await worker.trigger({
        function_id: "engine::triggers::list",
        payload: { include_internal: false },
      });

      // engine::registered-triggers::list
      const { registered_triggers } = await worker.trigger({
        function_id: "engine::registered-triggers::list",
        payload: { include_internal: false },
      });
      ```
    </Tab>

    <Tab title="Python">
      ```python theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      # engine::workers::list, pass {"worker_id": "<uuid>"} to look up one worker
      workers = worker.trigger({
          "function_id": "engine::workers::list",
          "payload": {},
      })["workers"]

      # engine::functions::list
      functions = worker.trigger({
          "function_id": "engine::functions::list",
          "payload": {"include_internal": False},
      })["functions"]

      # engine::triggers::list
      triggers = worker.trigger({
          "function_id": "engine::triggers::list",
          "payload": {"include_internal": False},
      })["triggers"]

      # engine::registered-triggers::list
      registered_triggers = worker.trigger({
          "function_id": "engine::registered-triggers::list",
          "payload": {"include_internal": False},
      })["registered_triggers"]
      ```
    </Tab>

    <Tab title="Rust">
      ```rust theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      use iii_sdk::protocol::TriggerRequest;
      use serde_json::json;

      // engine::workers::list, pass json!({ "worker_id": "<uuid>" }) to look up one worker
      let workers = worker
          .trigger(TriggerRequest {
              function_id: "engine::workers::list".into(),
              payload: json!({}),
              action: None,
              timeout_ms: None,
          })
          .await?;

      // engine::functions::list
      let functions = worker
          .trigger(TriggerRequest {
              function_id: "engine::functions::list".into(),
              payload: json!({ "include_internal": false }),
              action: None,
              timeout_ms: None,
          })
          .await?;

      // engine::triggers::list
      let triggers = worker
          .trigger(TriggerRequest {
              function_id: "engine::triggers::list".into(),
              payload: json!({ "include_internal": false }),
              action: None,
              timeout_ms: None,
          })
          .await?;

      // engine::registered-triggers::list
      let registered_triggers = worker
          .trigger(TriggerRequest {
              function_id: "engine::registered-triggers::list".into(),
              payload: json!({ "include_internal": false }),
              action: None,
              timeout_ms: None,
          })
          .await?;
      ```
    </Tab>
  </Tabs>
</Accordion>

### Handling Worker disconnects

When a Worker's WebSocket closes, the Engine cleans up after it automatically. Its Functions and
Triggers leave the live registry, and any in-flight invocations of those Functions are cancelled.

#### In flight requests

In flight requests will get a `invocation_stopped` error, catch these errors and treat them like a
cancellation. Retrying will fail until the Worker that owns this function reconnects.

<Accordion title="Example: catch `invocation_stopped`">
  <Tabs>
    <Tab title="Node / TypeScript">
      ```typescript theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      import { InvocationError } from "iii-sdk/errors";

      try {
        const result = await worker.trigger({
          function_id: "math::add",
          payload: { a: 1, b: 2 },
        });
      } catch (err) {
        if (err instanceof InvocationError && err.code === "invocation_stopped") {
          // Worker disconnected mid-invocation. Subscribe to `engine::functions-available`
          // (see "Subscribe to changes" below) to know when to retry.
          return;
        }
        throw err;
      }
      ```
    </Tab>

    <Tab title="Python">
      ```python theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      from iii.errors import InvocationError

      try:
          result = worker.trigger({
              "function_id": "math::add",
              "payload": {"a": 1, "b": 2},
          })
      except InvocationError as err:
          if err.code == "invocation_stopped":
              # Worker disconnected mid-invocation. Subscribe to `engine::functions-available`
              # (see "Subscribe to changes" below) to know when to retry.
              return
          raise
      ```
    </Tab>

    <Tab title="Rust">
      ```rust theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      use iii_sdk::Error;
      use iii_sdk::protocol::TriggerRequest;
      use serde_json::json;

      let result = worker
          .trigger(TriggerRequest {
              function_id: "math::add".into(),
              payload: json!({ "a": 1, "b": 2 }),
              action: None,
              timeout_ms: None,
          })
          .await;

      match result {
          Err(Error::Remote { code, .. }) if code == "invocation_stopped" => {
              // Worker disconnected mid-invocation. Subscribe to `engine::functions-available`
              // (see "Subscribe to changes" below) to know when to retry.
          }
          Err(e) => return Err(e.into()),
          Ok(value) => { /* use value */ }
      }
      ```
    </Tab>
  </Tabs>
</Accordion>

#### Subscribe to changes

You can register a Trigger against one of the engine's discovery events to react to topology changes
as they happen. This is particularly useful for continuing work when a Worker comes back online.

| Trigger                       | When it fires                             |
| ----------------------------- | ----------------------------------------- |
| `engine::workers-available`   | A Worker connects or disconnects.         |
| `engine::functions-available` | A Function is registered or unregistered. |

<Accordion title="Example: subscribe to discovery events">
  <Tabs>
    <Tab title="Node / TypeScript">
      ```typescript theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      worker.registerFunction(
        "discovery::on-workers",
        async (data: { event: string; worker_id: string }) => {
          if (data.event === "worker_connected") {
            // A Worker joined the registry; its Functions are callable now.
          }
        },
      );
      worker.registerTrigger({
        type: "engine::workers-available",
        function_id: "discovery::on-workers",
        config: {},
      });

      worker.registerFunction(
        "discovery::on-functions",
        async (data: { event: string; functions: { function_id: string }[] }) => {
          // `functions` is the full snapshot after the change.
          const ids = data.functions.map((f) => f.function_id);
        },
      );
      worker.registerTrigger({
        type: "engine::functions-available",
        function_id: "discovery::on-functions",
        config: {},
      });
      ```
    </Tab>

    <Tab title="Python">
      ```python theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      async def on_workers(data: dict) -> None:
          if data["event"] == "worker_connected":
              # A Worker joined the registry; its Functions are callable now.
              pass

      worker.register_function("discovery::on-workers", on_workers)
      worker.register_trigger({
          "type": "engine::workers-available",
          "function_id": "discovery::on-workers",
          "config": {},
      })

      async def on_functions(data: dict) -> None:
          # `functions` is the full snapshot after the change.
          ids = [f["function_id"] for f in data.get("functions", [])]

      worker.register_function("discovery::on-functions", on_functions)
      worker.register_trigger({
          "type": "engine::functions-available",
          "function_id": "discovery::on-functions",
          "config": {},
      })
      ```
    </Tab>

    <Tab title="Rust">
      ```rust theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
      use iii_sdk::RegisterFunction;
      use iii_sdk::protocol::RegisterTriggerInput;
      use schemars::JsonSchema;
      use serde::Deserialize;
      use serde_json::{Value, json};

      #[derive(Deserialize, JsonSchema)]
      struct WorkersAvailable { event: String, worker_id: String }

      #[derive(Deserialize, JsonSchema)]
      struct FunctionsAvailable { event: String, functions: Vec<Value> }

      worker.register_function(
          "discovery::on-workers",
          RegisterFunction::new_async(|input: WorkersAvailable| async move {
              if input.event == "worker_connected" {
                  // A Worker joined the registry; its Functions are callable now.
              }
              Ok::<_, iii_sdk::Error>(())
          }),
      );
      worker.register_trigger(RegisterTriggerInput {
          trigger_type: "engine::workers-available".into(),
          function_id: "discovery::on-workers".into(),
          config: json!({}),
          metadata: None,
      })?;

      worker.register_function(
          "discovery::on-functions",
          RegisterFunction::new_async(|input: FunctionsAvailable| async move {
              // `functions` is the full snapshot after the change.
              let _count = input.functions.len();
              Ok::<_, iii_sdk::Error>(())
          }),
      );
      worker.register_trigger(RegisterTriggerInput {
          trigger_type: "engine::functions-available".into(),
          function_id: "discovery::on-functions".into(),
          config: json!({}),
          metadata: None,
      })?;
      ```
    </Tab>
  </Tabs>
</Accordion>

## Worker manifest

`iii.worker.yaml` is the manifest at the worker's root that tells Compose how to start a local or
bundled worker. A local Compose container can override the manifest with its own `scripts.run` and
use `pre_run` or `post_run` for lifecycle hooks.

```yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
name: math-worker
description: Evaluate math expressions over iii functions.
runtime:
  # Base OCI image used as the worker rootfs. Override to pin a version.
  base_image: docker.io/iiidev/python:latest
scripts:
  start: "watchfiles 'python src/math_worker.py'"
```

`description` is an optional one-line, human/LLM-readable summary of what the worker does.

`scripts.start` launches the worker. Here, `watchfiles` reloads it whenever you edit a source file.
`runtime.base_image` selects the OCI image used for a bundled worker's root filesystem.

The manifest is metadata about *starting* the Worker. Once the Worker is running, iii treats a
Compose-managed process and a manually run process that uses the iii SDK identically.

<Note>
  If a worker isn't starting correctly, check its manifest, the Compose daemon output, and
  `iii trigger -n dev compose::status`.
</Note>

## Shutting down a worker

Call the SDK's `shutdown` to close the WebSocket cleanly. The engine removes the worker's Functions
and Triggers from the registry, fires `engine::workers-available` with `worker_disconnected`, and
cancels in-flight invocations targeting them with `invocation_stopped`.

Without `shutdown`, an abrupt process exit reaches the same state once the engine notices the
dropped socket; graceful shutdown makes it deterministic and faster.

<Tabs>
  <Tab title="Node / TypeScript">
    ```typescript theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    process.on("SIGTERM", async () => {
      await worker.shutdown();
      process.exit(0);
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    import signal

    def _on_term(*_):
        worker.shutdown()
        raise SystemExit(0)

    signal.signal(signal.SIGTERM, _on_term)
    ```
  </Tab>

  <Tab title="Rust">
    ```rust theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
    // Rust threads do not keep the process alive on their own; await this
    // before `main` returns so the connection thread exits cleanly.
    worker.shutdown_async().await;
    ```
  </Tab>
</Tabs>

<Note>
  Shutdown is useful for **One-shot / ephemeral workers**. Kubernetes Jobs, serverless containers,
  or scheduled scripts can connect like any other Worker, do their work, and `shutdown()`
  (`shutdown_async().await` in Rust).
</Note>
