diff --git a/.skill-check.yaml b/.skill-check.yaml index 64b2e8244..88134fa6a 100644 --- a/.skill-check.yaml +++ b/.skill-check.yaml @@ -8,6 +8,7 @@ docs: - "docs/0-10-0/**" - "docs/0-11-0/**" - "docs/changelog/**" + - "docs/**/changelog/**" ai_check: provider: anthropic model: claude-sonnet-4-6 diff --git a/docs/creating-workers/channels.mdx b/docs/creating-workers/channels.mdx new file mode 100644 index 000000000..510e7e5b5 --- /dev/null +++ b/docs/creating-workers/channels.mdx @@ -0,0 +1,332 @@ +--- +title: "Channels" +description: "Stream large or binary payloads between iii workers." +owner: "devrel" +type: "how-to" +--- + +Channels move large or binary payloads between iii workers without putting the data in a JSON +function payload. Use one when the payload is expected to be large (files, images, datasets), above +roughly **16 MB**, intended to be streamed (audio, video), or you want incremental progress updates +during long-running work. For small JSON, stick with a regular `worker.trigger(...)` call. + + + For the underlying model (how channels are addressed, multiplexed, and torn down), see [Channels + architecture](/understanding-iii/channels). + + +## Payload size + +iii itself doesn't enforce a maximum trigger-payload size. The effective ceiling comes from +whichever WebSocket library the engine and the calling SDK use, and each has its own defaults for +the per-frame and per-message size. The smallest common per-frame default sits around 16 MB, which +is the practical line at which you should switch to a channel. + +The libraries iii currently relies on: + +- **Engine** ([axum](https://docs.rs/axum) on top of + [tokio-tungstenite](https://docs.rs/tokio-tungstenite) ). See + [`WebSocketConfig`](https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocketConfig.html) + for `max_frame_size` and `max_message_size`. +- **Node SDK** ([ws](https://github.com/websockets/ws)). See the + [`maxPayload`](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback) + option. +- **Python SDK** ([websockets](https://websockets.readthedocs.io/)). See the + [`max_size`](https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html) option. +- **Rust SDK** ([tokio-tungstenite](https://docs.rs/tokio-tungstenite)) See + [`WebSocketConfig`](https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocketConfig.html), + same as the engine. + +These are library defaults and can shift between dependency versions; iii doesn't publish a hard +guaranteed cap. 16 MB is a safe default to follow. + +## Using channels + +A channel is created by one worker and has two local stream ends: `writer` and `reader`; plus two +serializable refs (`writerRef` / `readerRef`) that can be handed to another function. The +subsections below cover the local-end API: creating a channel, writing bytes into its `writer`, and +reading bytes from its `reader`. + +### Create a channel + +`worker.createChannel()` returns a channel with two local stream objects and two serializable refs: +`writer` and `reader` are the local stream ends, and `writerRef` / `readerRef` are the tokens you +pass to another function so it can read or write the other end. + + + + ```typescript + const channel = await worker.createChannel(); + + // channel.writer + // channel.reader + // channel.writerRef + // channel.readerRef + ``` + + + + ```python + channel = iii_client.create_channel() + + # channel.writer + # channel.reader + # channel.writer_ref + # channel.reader_ref + ``` + + + + ```rust + let channel = worker.create_channel(None).await?; + + // channel.writer + // channel.reader + // channel.writer_ref + // channel.reader_ref + ``` + + + + +### Write to a channel + +Write the payload to the local `writer` and close it when finished. The bytes flow through the +engine to whichever worker holds the matching `reader`. + + + + ```typescript + const channel = await worker.createChannel(); + + channel.writer.stream.end(Buffer.from("file contents")); + ``` + + + + ```python + channel = await iii_client.create_channel_async() + + await channel.writer.write(b"file contents") + await channel.writer.close_async() + ``` + + + + ```rust + let channel = worker.create_channel(None).await?; + + channel.writer.write(b"file contents").await?; + channel.writer.close().await?; + ``` + + + + +### Read from a channel + +Read the local `reader` until the other end closes. The bytes arrive in the order they were written +by whichever worker holds the matching `writer`. + + + + ```typescript + const channel = await worker.createChannel(); + + let bytes = 0; + for await (const chunk of channel.reader.stream) { + bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + } + ``` + + + + ```python + channel = await iii_client.create_channel_async() + + bytes_total = 0 + async for chunk in channel.reader: + bytes_total += len(chunk) + ``` + + + + ```rust + let channel = worker.create_channel(None).await?; + + let mut bytes = 0; + while let Some(chunk) = channel.reader.next_binary().await? { + bytes += chunk.len(); + } + ``` + + + + +## Using channels across functions + +A channel only becomes useful once both ends are owned by different code paths. Typically one +function that holds the local `writer` / `reader` and another that receives the matching ref in its +payload. The two subsections below cover that handoff: first how to send a ref alongside a normal +trigger call, then how the receiving function turns it back into a live stream to read or write. + +### Pass a channel ref to another function + +Pass the `readerRef` (or `writerRef`) as part of a normal function invocation. The receiving +function uses the ref to read from (or write to) the channel. + + + + ```typescript + const result = await worker.trigger({ + function_id: "files::process", + payload: { + filename: "report.csv", + reader: channel.readerRef, + }, + }); + ``` + + + ```python + result = await iii_client.trigger_async({ + "function_id": "files::process", + "payload": { + "filename": "report.csv", + "reader": channel.reader_ref.model_dump(), + }, + }) + ``` + + + ```rust + use iii_sdk::TriggerRequest; + use serde_json::json; + + let result = worker + .trigger(TriggerRequest { + function_id: "files::process".to_string(), + payload: json!({ + "filename": "report.csv", + "reader": channel.reader_ref, + }), + action: None, + timeout_ms: None, + }) + .await?; + ``` + + + + +Node and Python deserialize incoming channel refs into live `ChannelReader` / `ChannelWriter` +objects before the handler runs, so the ref arrives ready to iterate or write to. Rust receives the +ref in JSON and reconstructs the reader or writer explicitly with `ChannelReader::new(...)` or +`ChannelWriter::new(...)`. + +### Read from a channel ref + + + + ```typescript + import type { ChannelReader } from "iii-sdk"; + + worker.registerFunction("files::process", async (input: { reader: ChannelReader }) => { + let bytes = 0; + for await (const chunk of input.reader.stream) { + bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + } + return { bytes }; + }); + ``` + + + + ```python + async def process_file(input: dict) -> dict: + reader = input["reader"] + total = 0 + async for chunk in reader: + total += len(chunk) + return {"bytes": total} + + worker.register_function("files::process", process_file) + ``` + + + + ```rust + use iii_sdk::{ChannelDirection, ChannelReader, IIIError}; + use serde_json::json; + + let refs = iii_sdk::extract_channel_refs(&input); + let (_, reader_ref) = refs + .iter() + .find(|(k, r)| k == "reader" && matches!(r.direction, ChannelDirection::Read)) + .ok_or_else(|| IIIError::Handler("missing reader channel ref".into()))?; + + let reader = ChannelReader::new(worker.address(), reader_ref); + let mut bytes = 0; + while let Some(chunk) = reader.next_binary().await? { + bytes += chunk.len(); + } + Ok(json!({ "bytes": bytes })) + ``` + + + + +### Write to a channel ref + + + + ```typescript + import type { ChannelWriter } from "iii-sdk"; + + worker.registerFunction("files::generate", async (input: { writer: ChannelWriter }) => { + input.writer.stream.write(Buffer.from("hello ")); + input.writer.stream.end(Buffer.from("world")); + return { ok: true }; + }); + ``` + + + + ```python + async def generate_file(input: dict) -> dict: + writer = input["writer"] + await writer.write(b"hello ") + await writer.write(b"world") + await writer.close_async() + return {"ok": True} + + worker.register_function("files::generate", generate_file) + ``` + + + + ```rust + use iii_sdk::{ChannelDirection, ChannelWriter, IIIError}; + use serde_json::json; + + let refs = iii_sdk::extract_channel_refs(&input); + let (_, writer_ref) = refs + .iter() + .find(|(k, r)| k == "writer" && matches!(r.direction, ChannelDirection::Write)) + .ok_or_else(|| IIIError::Handler("missing writer channel ref".into()))?; + + let writer = ChannelWriter::new(worker.address(), writer_ref); + writer.write(b"hello ").await?; + writer.write(b"world").await?; + writer.close().await?; + Ok(json!({ "ok": true })) + ``` + + + + + + For the per-language channel API surface, see the SDK reference: + [Node](/sdk-reference/node-sdk#channels), [Python](/sdk-reference/python-sdk#channels), and + [Rust](/sdk-reference/rust-sdk#channels). + diff --git a/docs/creating-workers/channels.mdx.skill.md b/docs/creating-workers/channels.mdx.skill.md new file mode 100644 index 000000000..60654dc23 --- /dev/null +++ b/docs/creating-workers/channels.mdx.skill.md @@ -0,0 +1,328 @@ + + + +Channels move large or binary payloads between iii workers without putting the data in a JSON +function payload. Use one when the payload is expected to be large (files, images, datasets), above +roughly **16 MB**, intended to be streamed (audio, video), or you want incremental progress updates +during long-running work. For small JSON, stick with a regular `worker.trigger(...)` call. + + + For the underlying model (how channels are addressed, multiplexed, and torn down), see [Channels + architecture](/understanding-iii/channels). + + +## Payload size + +iii itself doesn't enforce a maximum trigger-payload size. The effective ceiling comes from +whichever WebSocket library the engine and the calling SDK use, and each has its own defaults for +the per-frame and per-message size. The smallest common per-frame default sits around 16 MB, which +is the practical line at which you should switch to a channel. + +The libraries iii currently relies on: + +- **Engine** ([axum](https://docs.rs/axum) on top of + [tokio-tungstenite](https://docs.rs/tokio-tungstenite) ). See + [`WebSocketConfig`](https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocketConfig.html) + for `max_frame_size` and `max_message_size`. +- **Node SDK** ([ws](https://github.com/websockets/ws)). See the + [`maxPayload`](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback) + option. +- **Python SDK** ([websockets](https://websockets.readthedocs.io/)). See the + [`max_size`](https://websockets.readthedocs.io/en/stable/reference/asyncio/client.html) option. +- **Rust SDK** ([tokio-tungstenite](https://docs.rs/tokio-tungstenite)) See + [`WebSocketConfig`](https://docs.rs/tungstenite/latest/tungstenite/protocol/struct.WebSocketConfig.html), + same as the engine. + +These are library defaults and can shift between dependency versions; iii doesn't publish a hard +guaranteed cap. 16 MB is a safe default to follow. + +## Using channels + +A channel is created by one worker and has two local stream ends: `writer` and `reader`; plus two +serializable refs (`writerRef` / `readerRef`) that can be handed to another function. The +subsections below cover the local-end API: creating a channel, writing bytes into its `writer`, and +reading bytes from its `reader`. + +### Create a channel + +`worker.createChannel()` returns a channel with two local stream objects and two serializable refs: +`writer` and `reader` are the local stream ends, and `writerRef` / `readerRef` are the tokens you +pass to another function so it can read or write the other end. + + + + ```typescript + const channel = await worker.createChannel(); + + // channel.writer + // channel.reader + // channel.writerRef + // channel.readerRef + ``` + + + + ```python + channel = iii_client.create_channel() + + # channel.writer + # channel.reader + # channel.writer_ref + # channel.reader_ref + ``` + + + + ```rust + let channel = worker.create_channel(None).await?; + + // channel.writer + // channel.reader + // channel.writer_ref + // channel.reader_ref + ``` + + + + +### Write to a channel + +Write the payload to the local `writer` and close it when finished. The bytes flow through the +engine to whichever worker holds the matching `reader`. + + + + ```typescript + const channel = await worker.createChannel(); + + channel.writer.stream.end(Buffer.from("file contents")); + ``` + + + + ```python + channel = await iii_client.create_channel_async() + + await channel.writer.write(b"file contents") + await channel.writer.close_async() + ``` + + + + ```rust + let channel = worker.create_channel(None).await?; + + channel.writer.write(b"file contents").await?; + channel.writer.close().await?; + ``` + + + + +### Read from a channel + +Read the local `reader` until the other end closes. The bytes arrive in the order they were written +by whichever worker holds the matching `writer`. + + + + ```typescript + const channel = await worker.createChannel(); + + let bytes = 0; + for await (const chunk of channel.reader.stream) { + bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + } + ``` + + + + ```python + channel = await iii_client.create_channel_async() + + bytes_total = 0 + async for chunk in channel.reader: + bytes_total += len(chunk) + ``` + + + + ```rust + let channel = worker.create_channel(None).await?; + + let mut bytes = 0; + while let Some(chunk) = channel.reader.next_binary().await? { + bytes += chunk.len(); + } + ``` + + + + +## Using channels across functions + +A channel only becomes useful once both ends are owned by different code paths. Typically one +function that holds the local `writer` / `reader` and another that receives the matching ref in its +payload. The two subsections below cover that handoff: first how to send a ref alongside a normal +trigger call, then how the receiving function turns it back into a live stream to read or write. + +### Pass a channel ref to another function + +Pass the `readerRef` (or `writerRef`) as part of a normal function invocation. The receiving +function uses the ref to read from (or write to) the channel. + + + + ```typescript + const result = await worker.trigger({ + function_id: "files::process", + payload: { + filename: "report.csv", + reader: channel.readerRef, + }, + }); + ``` + + + ```python + result = await iii_client.trigger_async({ + "function_id": "files::process", + "payload": { + "filename": "report.csv", + "reader": channel.reader_ref.model_dump(), + }, + }) + ``` + + + ```rust + use iii_sdk::TriggerRequest; + use serde_json::json; + + let result = worker + .trigger(TriggerRequest { + function_id: "files::process".to_string(), + payload: json!({ + "filename": "report.csv", + "reader": channel.reader_ref, + }), + action: None, + timeout_ms: None, + }) + .await?; + ``` + + + + +Node and Python deserialize incoming channel refs into live `ChannelReader` / `ChannelWriter` +objects before the handler runs, so the ref arrives ready to iterate or write to. Rust receives the +ref in JSON and reconstructs the reader or writer explicitly with `ChannelReader::new(...)` or +`ChannelWriter::new(...)`. + +### Read from a channel ref + + + + ```typescript + import type { ChannelReader } from "iii-sdk"; + + worker.registerFunction("files::process", async (input: { reader: ChannelReader }) => { + let bytes = 0; + for await (const chunk of input.reader.stream) { + bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + } + return { bytes }; + }); + ``` + + + + ```python + async def process_file(input: dict) -> dict: + reader = input["reader"] + total = 0 + async for chunk in reader: + total += len(chunk) + return {"bytes": total} + + worker.register_function("files::process", process_file) + ``` + + + + ```rust + use iii_sdk::{ChannelDirection, ChannelReader, IIIError}; + use serde_json::json; + + let refs = iii_sdk::extract_channel_refs(&input); + let (_, reader_ref) = refs + .iter() + .find(|(k, r)| k == "reader" && matches!(r.direction, ChannelDirection::Read)) + .ok_or_else(|| IIIError::Handler("missing reader channel ref".into()))?; + + let reader = ChannelReader::new(worker.address(), reader_ref); + let mut bytes = 0; + while let Some(chunk) = reader.next_binary().await? { + bytes += chunk.len(); + } + Ok(json!({ "bytes": bytes })) + ``` + + + + +### Write to a channel ref + + + + ```typescript + import type { ChannelWriter } from "iii-sdk"; + + worker.registerFunction("files::generate", async (input: { writer: ChannelWriter }) => { + input.writer.stream.write(Buffer.from("hello ")); + input.writer.stream.end(Buffer.from("world")); + return { ok: true }; + }); + ``` + + + + ```python + async def generate_file(input: dict) -> dict: + writer = input["writer"] + await writer.write(b"hello ") + await writer.write(b"world") + await writer.close_async() + return {"ok": True} + + worker.register_function("files::generate", generate_file) + ``` + + + + ```rust + use iii_sdk::{ChannelDirection, ChannelWriter, IIIError}; + use serde_json::json; + + let refs = iii_sdk::extract_channel_refs(&input); + let (_, writer_ref) = refs + .iter() + .find(|(k, r)| k == "writer" && matches!(r.direction, ChannelDirection::Write)) + .ok_or_else(|| IIIError::Handler("missing writer channel ref".into()))?; + + let writer = ChannelWriter::new(worker.address(), writer_ref); + writer.write(b"hello ").await?; + writer.write(b"world").await?; + writer.close().await?; + Ok(json!({ "ok": true })) + ``` + + + + + + For the per-language channel API surface, see the SDK reference: + [Node](/sdk-reference/node-sdk#channels), [Python](/sdk-reference/python-sdk#channels), and + [Rust](/sdk-reference/rust-sdk#channels). + diff --git a/docs/creating-workers/functions.mdx b/docs/creating-workers/functions.mdx index 9605fb99c..fd02f317b 100644 --- a/docs/creating-workers/functions.mdx +++ b/docs/creating-workers/functions.mdx @@ -7,31 +7,41 @@ type: "how-to" ## What "writing a function" means -A worker contributes capability by registering functions. Each function has an `id` of the form -`service::name`, a handler that receives the payload and returns a result, and optional JSON -Schemas that describe the request and response shape. +A worker contributes capabilities to a iii system by registering functions. Each function has an +`id` of the form `service::name`, a handler that receives the payload and returns a result, and +optional JSON Schemas that describe the request and response shape. For how callers invoke functions (`worker.trigger` / `iii trigger` / event-bound triggers), see -[Using iii / Functions](/using-iii/functions) and -[Using iii / Triggers](/using-iii/triggers). This page is about the authoring surface. +[Using iii / Triggers](/using-iii/triggers). This page is about the authoring new functions. + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} ## Register a function -Inside the worker, register the function with the SDK. The `id` is what callers pass as -`function_id`; the handler signature is the same regardless of how the invocation arrived (direct -call, HTTP trigger, cron, queue message). +Inside the worker, register the function with the SDK. The `id` is what triggers will use as a +`function_id`. + + + Each type of trigger has its own expected argument structure for a given function. For example + `cron` will call functions without arguments, while `http` will provide a standard http-style + payload that includes `body`, `headers`, and other properties. For each worker visit their + respective page at [workers.iii.dev](https://workers.iii.dev/) for their expected payload. + ```typescript import { registerWorker } from "iii-sdk"; - const worker = registerWorker(process.env.III_URL); + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url); worker.registerFunction("math::add", async (payload: { a: number; b: number }) => { return { c: payload.a + payload.b }; }); ``` + ```python @@ -48,6 +58,7 @@ call, HTTP trigger, cron, queue message). worker.register_function("math::add", add_handler) ``` + ```rust @@ -58,8 +69,9 @@ call, HTTP trigger, cron, queue message). worker.register_function(RegisterFunction::new("math::add", |input: AddInput| { Ok(serde_json::json!({ "c": input.a + input.b })) - }))?; + })); ``` + @@ -71,26 +83,32 @@ agent-readable skills. Runtime validation is not yet supported. Attached schemas are metadata only; the engine does not - reject payloads or handler return values that don't match them. + enforce a specific schema, reject payloads, nor handler return values that don't match the + schemas. Treat the schemas as contract documentation for function invocations, agents, and the + console. +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + ```typescript import { registerWorker } from "iii-sdk"; - const worker = registerWorker(process.env.III_URL); + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url); worker.registerFunction( "math::add", async (payload) => ({ c: payload.a + payload.b }), { - request_schema: { + request_format: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"], }, - response_schema: { + response_format: { type: "object", properties: { c: { type: "number" } }, required: ["c"], @@ -98,6 +116,7 @@ agent-readable skills. }, ); ``` + ```python @@ -112,61 +131,185 @@ agent-readable skills. worker.register_function( "math::add", add_handler, - request_schema={ + request_format={ "type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"], }, - response_schema={ + response_format={ "type": "object", "properties": {"c": {"type": "number"}}, "required": ["c"], }, ) ``` + ```rust use iii_sdk::{InitOptions, RegisterFunction, register_worker}; - use serde_json::json; + use schemars::JsonSchema; + use serde::Deserialize; + + #[derive(Deserialize, JsonSchema)] + struct AddInput { a: f64, b: f64 } + + #[derive(serde::Serialize, JsonSchema)] + struct AddOutput { c: f64 } let url = std::env::var("III_URL").expect("III_URL must be set"); let worker = register_worker(&url, InitOptions::default()); - worker.register_function( - RegisterFunction::new("math::add", |input: AddInput| { - Ok(serde_json::json!({ "c": input.a + input.b })) - }) - .request_schema(json!({ - "type": "object", - "properties": { "a": { "type": "number" }, "b": { "type": "number" } }, - "required": ["a", "b"], - })) - .response_schema(json!({ - "type": "object", - "properties": { "c": { "type": "number" } }, - "required": ["c"], - })), - )?; + // Rust derives `request_format` and `response_format` from the closure's + // input and output types via `schemars::JsonSchema`. No builder methods + // are needed — annotate your request/response structs and the SDK + // generates the schemas automatically. + worker.register_function(RegisterFunction::new( + "math::add", + |input: AddInput| -> Result { + Ok(AddOutput { c: input.a + input.b }) + }, + )); ``` + The schemas also feed the iii console and the agent-readable skills. +## HTTP-invokable functions + +You can also register an external HTTP endpoint as a function. The engine makes the HTTP call +whenever the function is invoked, your worker only declares the endpoint. + +This is useful for delegating work to your existing API Gateways, webhooks, serverless platforms +(Lambda, Azure Functions, Google Cloud Functions), or any third-party API you want to surface as a +regular iii function. + +The function is then triggerable like any other: `worker.trigger`, `iii trigger`, and any bound +trigger type (queue, cron, state, http) all work without any other changes. + + + + + ```typescript + 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); + + worker.registerFunction( + "notifications::send", + { + url: "https://hooks.provider.example.com/notify", + method: "POST", + timeout_ms: 5000, + headers: { "X-Service": "iii-worker" }, + auth: { type: "bearer", token_key: "PROVIDER_API_TOKEN" }, + }, + { description: "POST a notification to the provider webhook" }, + ); + ``` + + + ```python + import os + from iii import HttpInvocationConfig, InitOptions, register_worker + from iii.iii_types import HttpAuthBearer + + worker = register_worker( + os.environ.get("III_URL"), + InitOptions(worker_name="notifications-worker"), + ) + + worker.register_function( + "notifications::send", + HttpInvocationConfig( + url="https://hooks.provider.example.com/notify", + method="POST", + timeout_ms=5000, + headers={"X-Service": "iii-worker"}, + auth=HttpAuthBearer(token_key="PROVIDER_API_TOKEN"), + ), + description="POST a notification to the provider webhook", + ) + ``` + + + ```rust + use std::collections::HashMap; + use iii_sdk::{ + HttpAuthConfig, HttpInvocationConfig, HttpMethod, InitOptions, + RegisterFunctionMessage, register_worker, + }; + + let url = std::env::var("III_URL").expect("III_URL must be set"); + let worker = register_worker(&url, InitOptions::default()); + + let mut headers = HashMap::new(); + headers.insert("X-Service".into(), "iii-worker".into()); + + worker.register_function(( + RegisterFunctionMessage::with_id("notifications::send".into()) + .with_description("POST a notification to the provider webhook".into()), + HttpInvocationConfig { + url: "https://hooks.provider.example.com/notify".into(), + method: HttpMethod::Post, + timeout_ms: Some(5000), + headers, + auth: Some(HttpAuthConfig::Bearer { + token_key: "PROVIDER_API_TOKEN".into(), + }), + }, + )); + ``` + + + + + +### `HttpInvocationConfig` fields + +While a normal function takes an `id` and a `handler`, http invokeable functions take an `id` and a +`HttpInvocationConfig`. + +| Field | Type | Default | Description | +| ------------ | ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------- | +| `url` | `string` | (required) | Endpoint the engine calls when the function is invoked. | +| `method` | `"GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE"` | `"POST"` | HTTP method. | +| `timeout_ms` | `number` | `30000` | Per-request timeout in milliseconds. | +| `headers` | `Record` | `{}` | Headers added to every invocation. | +| `auth` | `HttpAuthConfig` | (none) | An object with two fields: `bearer`, `hmac`, or `api_key` AND `token_key`, `secret_key`, or `value_key` | + + + Auth fields (`token_key`, `secret_key`, `value_key`) are specified as the **names of environment + variables**, not the secrets themselves. The engine resolves them from its own process environment + at registration time, so secrets stay on the engine host and never travel over the SDK WebSocket. + + +### HTTP error handling + +The engine sends the invocation payload as the JSON request body and treats any non-2xx response or +network error as an invocation failure that propagates back to the caller. HTTP-invoked functions +appear in [`engine::functions::list`](/using-iii/functions#engine-functions-engine) and are +discoverable through the console exactly like in-process handlers. + ## Return values and errors A function returns either a value (which the handler is responsible for shaping to match its -documented response schema) or an error. Errors raised inside the handler are propagated to the -caller as invocation errors with the worker's stack trace; the engine doesn't swallow them. Use -this distinction to express expected failures (return a structured error value) versus unexpected -ones (throw / raise / return `Err`). +documented [response schema](#attach-request-and-response-schemas)) or an error. Errors raised +inside the handler are propagated to the caller as invocation errors with the worker's stack trace +attached: Node forwards `error.stack`, Python forwards `traceback.format_exc()`, and Rust forwards +the underlying error's stack trace. The engine doesn't swallow them. Use this distinction to express +expected failures (return a structured error value) versus unexpected ones (throw / raise / return +`Err`). ## Unregister a function -`registerFunction` returns a handle with an `unregister()` method that removes the function from -the engine at runtime. When the worker disconnects, all of its functions are removed automatically -and pending invocations error out. +`registerFunction` returns a handle with an `unregister()` method that removes the function from the +engine at runtime. When the worker disconnects, all of its functions are removed automatically and +pending invocations error out. @@ -177,6 +320,7 @@ and pending invocations error out. add.unregister(); ``` + ```python @@ -184,6 +328,7 @@ and pending invocations error out. add.unregister() ``` + ```rust @@ -193,12 +338,6 @@ and pending invocations error out. add.unregister(); ``` + - -## What goes in Worker Docs - -The function ids your worker exposes, what each one does, and any worker-specific semantics -(idempotency, rate limits, side effects) belong in this worker's Worker Docs. Keep iii-level -concepts (the registration surface, schema metadata, error propagation) here; document the -per-function specifics there. diff --git a/docs/creating-workers/functions.mdx.skill.md b/docs/creating-workers/functions.mdx.skill.md index f9ff86224..2af482657 100644 --- a/docs/creating-workers/functions.mdx.skill.md +++ b/docs/creating-workers/functions.mdx.skill.md @@ -5,31 +5,41 @@ ## What "writing a function" means -A worker contributes capability by registering functions. Each function has an `id` of the form -`service::name`, a handler that receives the payload and returns a result, and optional JSON -Schemas that describe the request and response shape. +A worker contributes capabilities to a iii system by registering functions. Each function has an +`id` of the form `service::name`, a handler that receives the payload and returns a result, and +optional JSON Schemas that describe the request and response shape. For how callers invoke functions (`worker.trigger` / `iii trigger` / event-bound triggers), see -[Using iii / Functions](/using-iii/functions) and -[Using iii / Triggers](/using-iii/triggers). This page is about the authoring surface. +[Using iii / Triggers](/using-iii/triggers). This page is about the authoring new functions. + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} ## Register a function -Inside the worker, register the function with the SDK. The `id` is what callers pass as -`function_id`; the handler signature is the same regardless of how the invocation arrived (direct -call, HTTP trigger, cron, queue message). +Inside the worker, register the function with the SDK. The `id` is what triggers will use as a +`function_id`. + + + Each type of trigger has its own expected argument structure for a given function. For example + `cron` will call functions without arguments, while `http` will provide a standard http-style + payload that includes `body`, `headers`, and other properties. For each worker visit their + respective page at [workers.iii.dev](https://workers.iii.dev/) for their expected payload. + ```typescript import { registerWorker } from "iii-sdk"; - const worker = registerWorker(process.env.III_URL); + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url); worker.registerFunction("math::add", async (payload: { a: number; b: number }) => { return { c: payload.a + payload.b }; }); ``` + ```python @@ -46,6 +56,7 @@ call, HTTP trigger, cron, queue message). worker.register_function("math::add", add_handler) ``` + ```rust @@ -56,8 +67,9 @@ call, HTTP trigger, cron, queue message). worker.register_function(RegisterFunction::new("math::add", |input: AddInput| { Ok(serde_json::json!({ "c": input.a + input.b })) - }))?; + })); ``` + @@ -69,26 +81,32 @@ agent-readable skills. Runtime validation is not yet supported. Attached schemas are metadata only; the engine does not - reject payloads or handler return values that don't match them. + enforce a specific schema, reject payloads, nor handler return values that don't match the + schemas. Treat the schemas as contract documentation for function invocations, agents, and the + console. +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + ```typescript import { registerWorker } from "iii-sdk"; - const worker = registerWorker(process.env.III_URL); + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url); worker.registerFunction( "math::add", async (payload) => ({ c: payload.a + payload.b }), { - request_schema: { + request_format: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"], }, - response_schema: { + response_format: { type: "object", properties: { c: { type: "number" } }, required: ["c"], @@ -96,6 +114,7 @@ agent-readable skills. }, ); ``` + ```python @@ -110,61 +129,185 @@ agent-readable skills. worker.register_function( "math::add", add_handler, - request_schema={ + request_format={ "type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"], }, - response_schema={ + response_format={ "type": "object", "properties": {"c": {"type": "number"}}, "required": ["c"], }, ) ``` + ```rust use iii_sdk::{InitOptions, RegisterFunction, register_worker}; - use serde_json::json; + use schemars::JsonSchema; + use serde::Deserialize; + + #[derive(Deserialize, JsonSchema)] + struct AddInput { a: f64, b: f64 } + + #[derive(serde::Serialize, JsonSchema)] + struct AddOutput { c: f64 } let url = std::env::var("III_URL").expect("III_URL must be set"); let worker = register_worker(&url, InitOptions::default()); - worker.register_function( - RegisterFunction::new("math::add", |input: AddInput| { - Ok(serde_json::json!({ "c": input.a + input.b })) - }) - .request_schema(json!({ - "type": "object", - "properties": { "a": { "type": "number" }, "b": { "type": "number" } }, - "required": ["a", "b"], - })) - .response_schema(json!({ - "type": "object", - "properties": { "c": { "type": "number" } }, - "required": ["c"], - })), - )?; + // Rust derives `request_format` and `response_format` from the closure's + // input and output types via `schemars::JsonSchema`. No builder methods + // are needed — annotate your request/response structs and the SDK + // generates the schemas automatically. + worker.register_function(RegisterFunction::new( + "math::add", + |input: AddInput| -> Result { + Ok(AddOutput { c: input.a + input.b }) + }, + )); ``` + The schemas also feed the iii console and the agent-readable skills. +## HTTP-invokable functions + +You can also register an external HTTP endpoint as a function. The engine makes the HTTP call +whenever the function is invoked, your worker only declares the endpoint. + +This is useful for delegating work to your existing API Gateways, webhooks, serverless platforms +(Lambda, Azure Functions, Google Cloud Functions), or any third-party API you want to surface as a +regular iii function. + +The function is then triggerable like any other: `worker.trigger`, `iii trigger`, and any bound +trigger type (queue, cron, state, http) all work without any other changes. + + + + + ```typescript + 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); + + worker.registerFunction( + "notifications::send", + { + url: "https://hooks.provider.example.com/notify", + method: "POST", + timeout_ms: 5000, + headers: { "X-Service": "iii-worker" }, + auth: { type: "bearer", token_key: "PROVIDER_API_TOKEN" }, + }, + { description: "POST a notification to the provider webhook" }, + ); + ``` + + + ```python + import os + from iii import HttpInvocationConfig, InitOptions, register_worker + from iii.iii_types import HttpAuthBearer + + worker = register_worker( + os.environ.get("III_URL"), + InitOptions(worker_name="notifications-worker"), + ) + + worker.register_function( + "notifications::send", + HttpInvocationConfig( + url="https://hooks.provider.example.com/notify", + method="POST", + timeout_ms=5000, + headers={"X-Service": "iii-worker"}, + auth=HttpAuthBearer(token_key="PROVIDER_API_TOKEN"), + ), + description="POST a notification to the provider webhook", + ) + ``` + + + ```rust + use std::collections::HashMap; + use iii_sdk::{ + HttpAuthConfig, HttpInvocationConfig, HttpMethod, InitOptions, + RegisterFunctionMessage, register_worker, + }; + + let url = std::env::var("III_URL").expect("III_URL must be set"); + let worker = register_worker(&url, InitOptions::default()); + + let mut headers = HashMap::new(); + headers.insert("X-Service".into(), "iii-worker".into()); + + worker.register_function(( + RegisterFunctionMessage::with_id("notifications::send".into()) + .with_description("POST a notification to the provider webhook".into()), + HttpInvocationConfig { + url: "https://hooks.provider.example.com/notify".into(), + method: HttpMethod::Post, + timeout_ms: Some(5000), + headers, + auth: Some(HttpAuthConfig::Bearer { + token_key: "PROVIDER_API_TOKEN".into(), + }), + }, + )); + ``` + + + + + +### `HttpInvocationConfig` fields + +While a normal function takes an `id` and a `handler`, http invokeable functions take an `id` and a +`HttpInvocationConfig`. + +| Field | Type | Default | Description | +| ------------ | ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------- | +| `url` | `string` | (required) | Endpoint the engine calls when the function is invoked. | +| `method` | `"GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE"` | `"POST"` | HTTP method. | +| `timeout_ms` | `number` | `30000` | Per-request timeout in milliseconds. | +| `headers` | `Record` | `{}` | Headers added to every invocation. | +| `auth` | `HttpAuthConfig` | (none) | An object with two fields: `bearer`, `hmac`, or `api_key` AND `token_key`, `secret_key`, or `value_key` | + + + Auth fields (`token_key`, `secret_key`, `value_key`) are specified as the **names of environment + variables**, not the secrets themselves. The engine resolves them from its own process environment + at registration time, so secrets stay on the engine host and never travel over the SDK WebSocket. + + +### HTTP error handling + +The engine sends the invocation payload as the JSON request body and treats any non-2xx response or +network error as an invocation failure that propagates back to the caller. HTTP-invoked functions +appear in [`engine::functions::list`](/using-iii/functions#engine-functions-engine) and are +discoverable through the console exactly like in-process handlers. + ## Return values and errors A function returns either a value (which the handler is responsible for shaping to match its -documented response schema) or an error. Errors raised inside the handler are propagated to the -caller as invocation errors with the worker's stack trace; the engine doesn't swallow them. Use -this distinction to express expected failures (return a structured error value) versus unexpected -ones (throw / raise / return `Err`). +documented [response schema](#attach-request-and-response-schemas)) or an error. Errors raised +inside the handler are propagated to the caller as invocation errors with the worker's stack trace +attached: Node forwards `error.stack`, Python forwards `traceback.format_exc()`, and Rust forwards +the underlying error's stack trace. The engine doesn't swallow them. Use this distinction to express +expected failures (return a structured error value) versus unexpected ones (throw / raise / return +`Err`). ## Unregister a function -`registerFunction` returns a handle with an `unregister()` method that removes the function from -the engine at runtime. When the worker disconnects, all of its functions are removed automatically -and pending invocations error out. +`registerFunction` returns a handle with an `unregister()` method that removes the function from the +engine at runtime. When the worker disconnects, all of its functions are removed automatically and +pending invocations error out. @@ -175,6 +318,7 @@ and pending invocations error out. add.unregister(); ``` + ```python @@ -182,6 +326,7 @@ and pending invocations error out. add.unregister() ``` + ```rust @@ -191,12 +336,6 @@ and pending invocations error out. add.unregister(); ``` + - -## What goes in Worker Docs - -The function ids your worker exposes, what each one does, and any worker-specific semantics -(idempotency, rate limits, side effects) belong in this worker's Worker Docs. Keep iii-level -concepts (the registration surface, schema metadata, error propagation) here; document the -per-function specifics there. diff --git a/docs/creating-workers/index.mdx b/docs/creating-workers/index.mdx new file mode 100644 index 000000000..5e76ae01f --- /dev/null +++ b/docs/creating-workers/index.mdx @@ -0,0 +1,41 @@ +--- +title: "Overview" +description: + "Every new capability in iii is added by writing a Worker, never by extending the Engine." +owner: "devrel" +type: "explanation" +--- + +Every new capability in a iii system is added by creating a Worker. A queue, a scheduler, an HTTP +edge, a browser tab, an agent, a CRM integration, or a sandbox. Each is a Worker that connects to +the Engine and registers Triggers and Functions. The Engine itself never needs to change. + +## Workers are the services + +A Worker is the unit of capability in iii. It is the service. When you need new behaviour, you write +a new Worker (or install an existing one from the registry); you do not patch the Engine, add a +plugin to it, or fork it. The Engine is a fixed coordinator, it routes invocations between Workers +and maintains the live registry of what each Worker provides. Everything that makes a iii system do +something useful runs in a Worker. + +This is why "add a Worker" is the answer to almost every "how do I add X to iii?" question. If a +capability is missing, the gap is filled by a Worker, not by a change to iii itself. + + + For the mental model behind Workers, Triggers, Functions, and the Engine, see [Understanding iii / + Overview](/understanding-iii). To build Workers continue with this section. + + +## What's in this section + + + + Connect a Worker to the Engine and deploy it. + + + Declare what causes those Functions to run. + + + Register the Functions a Worker contributes. + + diff --git a/docs/creating-workers/index.mdx.skill.md b/docs/creating-workers/index.mdx.skill.md new file mode 100644 index 000000000..fe88fe708 --- /dev/null +++ b/docs/creating-workers/index.mdx.skill.md @@ -0,0 +1,38 @@ + + +# Overview + + +Every new capability in a iii system is added by creating a Worker. A queue, a scheduler, an HTTP +edge, a browser tab, an agent, a CRM integration, or a sandbox. Each is a Worker that connects to +the Engine and registers Triggers and Functions. The Engine itself never needs to change. + +## Workers are the services + +A Worker is the unit of capability in iii. It is the service. When you need new behaviour, you write +a new Worker (or install an existing one from the registry); you do not patch the Engine, add a +plugin to it, or fork it. The Engine is a fixed coordinator, it routes invocations between Workers +and maintains the live registry of what each Worker provides. Everything that makes a iii system do +something useful runs in a Worker. + +This is why "add a Worker" is the answer to almost every "how do I add X to iii?" question. If a +capability is missing, the gap is filled by a Worker, not by a change to iii itself. + + + For the mental model behind Workers, Triggers, Functions, and the Engine, see [Understanding iii / + Overview](/understanding-iii). To build Workers continue with this section. + + +## What's in this section + + + + Connect a Worker to the Engine and deploy it. + + + Declare what causes those Functions to run. + + + Register the Functions a Worker contributes. + + diff --git a/docs/creating-workers/triggers.mdx b/docs/creating-workers/triggers.mdx index 5f35f384c..d072574c1 100644 --- a/docs/creating-workers/triggers.mdx +++ b/docs/creating-workers/triggers.mdx @@ -1,201 +1,453 @@ --- title: "Triggers" -description: "Author new trigger types so other workers' functions can run on the events your worker emits." +description: + "Author new trigger types so other workers' functions can run on the events your worker emits." owner: "devrel" type: "how-to" --- ## What "writing a trigger" means -Workers don't only call functions; they can also _source_ events. When a worker advertises a -trigger type (e.g. `webhook`, a custom schedule, or any external event source you implement), any -other worker can bind its functions to that type. The engine routes binding requests to your -trigger type's handler, and your worker invokes the bound functions when the underlying event -fires. +A worker uses triggers two ways. Most of the time, you bind the worker's functions by registering a +trigger on an existing trigger type such as: [`http`](https://workers.iii.dev/workers/iii-http), +[`cron`](https://workers.iii.dev/workers/iii-cron), +[queue messages](https://workers.iii.dev/workers/iii-queue), +[`state` changes](https://workers.iii.dev/workers/iii-state), and any other event source in the +system. Less often, you register a new trigger type from your worker so other workers can bind their +functions to events your worker emits. -This page is about authoring new trigger types from inside your worker. For invoking functions and -binding triggers to functions from the consumer side, see [Using iii / Triggers](/using-iii/triggers). +This page primarily covers the latter: making your own triggers, if you want to use existing +triggers in new workers then refer to [Using iii / Triggers](/using-iii/triggers). -## Declare a trigger type + + For the caller-side mechanics (direct invocation with `worker.trigger` or `iii trigger`, the + `TriggerAction` variants, gating with conditions, multiple bindings per function), see [Using iii + / Triggers](/using-iii/triggers). + -Inside the worker, register the trigger type once during startup. You pass an `id` and -`description` plus a handler with `registerTrigger` / `unregisterTrigger` callbacks. The engine -calls these whenever a consumer binds or unbinds a function against your trigger type, so your -worker can keep its own table of `{ trigger id → function id, config }` bindings. +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} -`registerTriggerType` returns a typed handle. Use it later to bind functions you own to this -trigger type, or to tear the type down. +## Bind a function to an existing trigger type + +Most workers consume trigger types that other workers already publish: `http` from +[iii-http](https://workers.iii.dev/workers/iii-http) to expose a function as an endpoint, `cron` +from [iii-cron](https://workers.iii.dev/workers/iii-cron) to run a function on a schedule, queue +triggers from [iii-queue](https://workers.iii.dev/workers/iii-queue) to fire a function on each +message, `state` from [iii-state](https://workers.iii.dev/workers/iii-state) to react to data +changes. Bind one of your worker's functions to a trigger type with +`worker.registerTrigger({ type, function_id, config })`. The worker that publishes the trigger type +must be connected when you register; otherwise the registration fails. ```typescript - import { registerWorker } from "iii-sdk"; - import type { TriggerConfig, TriggerHandler } from "iii-sdk"; - - const worker = registerWorker(process.env.III_URL); - - type WebhookTriggerConfig = { - path: string; - secret?: string; - }; - - const webhookHandler: TriggerHandler = { - async registerTrigger(config: TriggerConfig) { - // Stash { config.id, config.function_id, config.config } so the - // worker's HTTP listener knows which function to invoke when a - // request matches `config.config.path`. - }, - async unregisterTrigger(config: TriggerConfig) { - // Remove the binding from the worker's table. - }, - }; - - const webhook = worker.registerTriggerType( - { id: "webhook", description: "Incoming webhook trigger" }, - webhookHandler, - ); + worker.registerTrigger({ + type: "http", + function_id: "math::add", + config: { api_path: "/math/add", http_method: "POST" }, + }); ``` ```python - import os - from iii import ( - InitOptions, - RegisterTriggerTypeInput, - TriggerConfig, - TriggerHandler, - register_worker, - ) - - worker = register_worker( - os.environ.get("III_URL"), - InitOptions(worker_name="webhook-worker"), - ) - - class WebhookHandler(TriggerHandler): - async def register_trigger(self, config: TriggerConfig) -> None: - # Stash config.id, config.function_id, config.config - ... - - async def unregister_trigger(self, config: TriggerConfig) -> None: - ... - - webhook = worker.register_trigger_type( - RegisterTriggerTypeInput(id="webhook", description="Incoming webhook trigger"), - WebhookHandler(), - ) + worker.register_trigger({ + "type": "http", + "function_id": "math::add", + "config": {"api_path": "/math/add", "http_method": "POST"}, + }) ``` ```rust - use iii_sdk::{ - InitOptions, RegisterTriggerType, TriggerConfig, TriggerHandler, register_worker, - }; - - let url = std::env::var("III_URL").expect("III_URL must be set"); - let worker = register_worker(&url, InitOptions::default()); - - struct WebhookHandler; - - #[async_trait::async_trait] - impl TriggerHandler for WebhookHandler { - async fn register_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { - // Stash config.id, config.function_id, config.config - Ok(()) - } - - async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { - Ok(()) - } - } + use iii_sdk::RegisterTriggerInput; + use serde_json::json; - let webhook = worker.register_trigger_type( - RegisterTriggerType::new("webhook", "Incoming webhook trigger", WebhookHandler), - ); + worker.register_trigger(RegisterTriggerInput { + trigger_type: "http".into(), + function_id: "math::add".into(), + config: json!({ "api_path": "/math/add", "http_method": "POST" }), + metadata: None, + })?; ``` + -For typed `config` and call-payload schemas, attach Pydantic, Zod, or `schemars::JsonSchema` types -to the registration in your language of choice. See each SDK's reference for the exact builder. +The `config` shape is defined per trigger type and documented in each publishing worker's +[Worker Docs](https://workers.iii.dev). -## Dispatch events to bound functions + + Other binding mechanics are covered in [Using iii / + Triggers](/using-iii/triggers#register-a-trigger): the unregister handle, binding multiple + triggers to the same function, gating with `condition_function_id`, and the `TriggerAction` + variants (`Void`, `Enqueue`, etc.). + -There is no special "fire" API. When the underlying event source delivers something (an incoming -HTTP request, a cron tick, a webhook hit), your worker looks up the bindings it stashed in its -`registerTrigger` callback and invokes each bound function via `worker.trigger(...)`. +## Attach metadata to a trigger + +Each trigger binding accepts an optional `metadata` JSON object set by the consumer at registration +time. The engine stores it as-is and surfaces it in two places: + +1. The publishing worker's `TriggerHandler.registerTrigger(config)` callback sees it as + `config.metadata`, so the publisher can act on consumer-supplied tags (priority hints, audit + labels, routing keys for the publisher's own bookkeeping). +2. [`engine::triggers::list`](/using-iii/functions#engine-functions-engine) returns it on each + `TriggerInfo`, so the console and any other worker doing discovery can read it. ```typescript - // Inside the worker's HTTP listener, after matching a request to a binding: - await worker.trigger({ - function_id: binding.function_id, - payload: { method, headers, body }, + worker.registerTrigger({ + type: "http", + function_id: "math::add", + config: { api_path: "/math/add", http_method: "POST" }, + metadata: { team: "platform", env: "staging" }, }); ``` ```python - # Inside the worker's HTTP listener, after matching a request to a binding: - worker.trigger({ - "function_id": binding["function_id"], - "payload": {"method": method, "headers": headers, "body": body}, + worker.register_trigger({ + "type": "http", + "function_id": "math::add", + "config": {"api_path": "/math/add", "http_method": "POST"}, + "metadata": {"team": "platform", "env": "staging"}, }) ``` ```rust - use iii_sdk::TriggerRequest; + use iii_sdk::RegisterTriggerInput; use serde_json::json; - // Inside the worker's HTTP listener, after matching a request to a binding: - worker - .trigger(TriggerRequest { - function_id: binding.function_id.clone(), - payload: json!({ "method": method, "headers": headers, "body": body }), - action: None, - timeout_ms: None, - }) - .await?; + worker.register_trigger(RegisterTriggerInput { + trigger_type: "http".into(), + function_id: "math::add".into(), + config: json!({ "api_path": "/math/add", "http_method": "POST" }), + metadata: Some(json!({ "team": "platform", "env": "staging" })), + })?; ``` + -On each dispatched event, the engine evaluates the consumer's `config` and optional -`condition_function_id`, then routes matching invocations to the bound function and returns the -result to the caller. + + Trigger _types_ have no metadata field of their own. Metadata is attached per binding, not per + type. + +Don't confuse trigger _metadata_ with trigger type [_schemas_](#attach-schemas-to-the-trigger-type) +(`trigger_request_format` and `call_request_format`): + +- **Metadata** is set by the **consumer** on each binding (ie. the `worker.registerTrigger()` call). + It's a free-form tag bag the engine stores as-is, used for the publisher's bookkeeping and for + discovery. For example, a consumer binding to `http` might attach + `metadata: { team: "platform", env: "staging", on_call: "alice" }` so the publishing worker can + log the team, and [`engine::triggers::list`](/using-iii/functions#engine-functions-engine) + surfaces this information on request. +- **Schemas** are set by the **publisher** when declaring the trigger type. They document the JSON + shapes the consumer interacts with. For example, the `http` type published by + [iii-http](https://workers.iii.dev/workers/iii-http) declares: + - `config` (what the consumer passes at bind time): `{ api_path, http_method }`. + - invocation payload (what their bound function receives on each request): + `{ method, headers, query_params, body }`. + + + +## Declaring a Trigger Type + +So far this documentation has focused on being the _consumer_: your worker's functions get bound to +trigger types other workers publish. This section flips the roles. Your worker is now the +_publisher_, and you want functions that other workers register to fire on events your worker +observes (an HTTP request, a webhook hit, a file change, a database update). + +### Components of a Trigger Type + +A trigger type is two things bundled together: + +1. A string `id` that consumers reference when they bind to a trigger (ex. `type: "mini-http"`). +2. A per-binding routing table that **your worker** maintains in-process. The engine's registry + records the binding canonically (this is what + [`engine::triggers::list`](/using-iii/functions#engine-functions-engine) returns), but the engine + doesn't dispatch on it. The engine forwards every bind/unbind from any consumer worker on the + network to the publisher worker as a callback, and this worker decides what to do with each one. + +You declare the trigger type once at startup with +`worker.registerTriggerType({ id, description }, handler)`. + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +The `TriggerHandler` interface **you implement** exposes two callbacks. The engine invokes them on +your publisher worker whenever a consumer binds or unbinds: + +- `registerTrigger(config)`: Runs when any consumer worker binds a function to your trigger type. + The `config` carries the trigger instance's `id`, the consumer's `function_id`, and the + consumer-supplied `config` matching the shape your type accepts. Stash it. +- `unregisterTrigger(config)`: Runs on unbind. Drop it from your table. + +The trigger type can be torn down at any point during runtime with +`worker.unregisterTriggerType(...)` (or `worker.unregister_trigger_type(...)` in Python and Rust). +See the [Unregister a Trigger Type](#unregister-a-trigger-type) section below for per-language +signatures. + +### Example: A mini `iii-http` from scratch + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +The example below sketches a tiny version of [iii-http](https://workers.iii.dev/workers/iii-http), +the worker that publishes the real `http` trigger type. The publisher worker: + +1. Declares an HTTP-shaped trigger type called `mini-http` +1. Maintains a `bindings` map of `{ trigger id → function_id, method+path }` as consumers + bind/unbind +1. Is then ready to look up the right binding when an HTTP request is received. Firing the bound + function is covered in [Dispatch events to bound functions](#dispatch-events-to-bound-functions) + below. + + + + + ```typescript + import { registerWorker } from "iii-sdk"; + import type { TriggerConfig, TriggerHandler } from "iii-sdk"; + + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url); + + type MiniHttpConfig = { + api_path: string; // leading slash, e.g. "/orders" + http_method?: "GET" | "POST" | "PUT" | "DELETE"; + }; + + const bindings = new Map>(); + + const httpHandler: TriggerHandler = { + async registerTrigger(config) { + bindings.set(config.id, config); + }, + async unregisterTrigger(config) { + bindings.delete(config.id); + }, + }; + + worker.registerTriggerType( + { id: "mini-http", description: "Routes HTTP requests to bound functions" }, + httpHandler, + ); + ``` + + + ```python + import os + from iii import ( + InitOptions, + RegisterTriggerTypeInput, + TriggerConfig, + TriggerHandler, + register_worker, + ) + + worker = register_worker( + os.environ.get("III_URL"), + InitOptions(worker_name="mini-http-worker"), + ) + + bindings: dict[str, TriggerConfig] = {} -## Unregister a trigger type + class HttpHandler(TriggerHandler): + async def register_trigger(self, config: TriggerConfig) -> None: + bindings[config.id] = config -`registerTriggerType` returns a handle with an `unregister()` method that tears down the trigger -type at runtime. When the worker disconnects, all trigger types it advertised are removed -automatically and the engine stops routing events that depended on them. + async def unregister_trigger(self, config: TriggerConfig) -> None: + bindings.pop(config.id, None) + + worker.register_trigger_type( + RegisterTriggerTypeInput( + id="mini-http", + description="Routes HTTP requests to bound functions", + ), + HttpHandler(), + ) + ``` + + + ```rust + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use iii_sdk::{ + InitOptions, RegisterTriggerType, TriggerConfig, TriggerHandler, register_worker, + }; + + let url = std::env::var("III_URL").expect("III_URL must be set"); + let worker = register_worker(&url, InitOptions::default()); + + #[derive(Default)] + struct HttpHandler { + bindings: Arc>>, + } + + #[async_trait::async_trait] + impl TriggerHandler for HttpHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { + self.bindings.lock().unwrap().insert(config.id.clone(), config); + Ok(()) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { + self.bindings.lock().unwrap().remove(&config.id); + Ok(()) + } + } + + worker.register_trigger_type( + RegisterTriggerType::new( + "mini-http", + "Routes HTTP requests to bound functions", + HttpHandler::default(), + ), + ); + ``` + + + + + +### Attach schemas to the trigger type + +A trigger type can carry two optional JSON Schemas that describe its payloads: + +- **`trigger_request_format`**: The schema for the per-binding `config` consumers pass to + `worker.registerTrigger(...)` when they bind a function to your trigger type. +- **`call_request_format`**: The schema for the payload your worker delivers to bound functions when + the trigger fires. + +Both feed the iii console, the agent-readable skills, and the +[`engine::trigger-types::list`](/using-iii/functions#engine-functions-engine) output so consumers +know what to send and what they'll receive. + + + Runtime validation is not yet supported. Attached schemas are informational only; the engine does + not reject `config` values or call payloads that don't match them. Treat the schemas as contract + documentation for consumers, agents, and the console; same caveat as [function request / response + schemas](/creating-workers/functions#attach-request-and-response-schemas). + + +Each SDK accepts these in its own idiomatic way: + +| SDK | What you pass | +| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| [Node](/sdk-reference/node-sdk#registertriggertype) / [Browser](/sdk-reference/browser-sdk#registertriggertype) | Raw JSON Schema objects on `trigger_request_format` / `call_request_format`. Convert Zod 4+ schemas with `z.toJSONSchema(...)`. | +| [Python](/sdk-reference/python-sdk#register_trigger_type) | A Pydantic model class (auto-converted) or a raw dict on the same fields of `RegisterTriggerTypeInput`. | +| [Rust](/sdk-reference/rust-sdk#register_trigger_type) | Builder methods on `RegisterTriggerType`: `.trigger_request_format::()` and `.call_request_format::()`, where `T: schemars::JsonSchema`. | + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +### Unregister a Trigger Type + +Tear down a trigger type at runtime when the work it routes is no longer needed. When the worker +disconnects, all trigger types it advertised are removed automatically and the engine stops routing +events that depended on them, so this step is only necessary if you want to drop a type while the +worker stays connected. + +Call it any time after `registerTriggerType` while the worker stays connected (e.g. the underlying +resource went into maintenance mode, a feature flag turned the surface off, or you want to rotate +the type to a new schema without restarting). Continuing the `mini-http` example, here the worker +drops `mini-http` because its HTTP listener was disabled by config: ```typescript - webhook.unregister(); + // e.g. config reload disabled the HTTP listener; stop accepting new bindings + // while the worker keeps serving other trigger types. + worker.unregisterTriggerType({ + id: "mini-http", + description: "Routes HTTP requests to bound functions", + }); ``` ```python + # e.g. config reload disabled the HTTP listener; stop accepting new bindings + # while the worker keeps serving other trigger types. worker.unregister_trigger_type( - RegisterTriggerTypeInput(id="webhook", description="Incoming webhook trigger"), + {"id": "mini-http", "description": "Routes HTTP requests to bound functions"} ) ``` ```rust - worker.unregister_trigger_type("webhook"); + // e.g. config reload disabled the HTTP listener; stop accepting new bindings + // while the worker keeps serving other trigger types. + worker.unregister_trigger_type("mini-http"); ``` -## What goes in Worker Docs +{/* TODO: Update once this inconsistency is fixed */} + + + Node's `registerTriggerType` also returns a `TriggerTypeRef` with an `.unregister()` shortcut that + delegates to `worker.unregisterTriggerType(...)`. Python's `TriggerTypeRef` only exposes + `register_trigger` and `register_function`; tear down the trigger type itself via + `worker.unregister_trigger_type(...)`. Rust takes only the `id` string; Node and Python take the + full input object but only the `id` field is used to identify the type being torn down. + + +## Dispatch events to bound functions + +There is no special "fire" API. When the underlying event source delivers something (an incoming +HTTP request, a cron tick, a webhook hit), your publisher worker looks up the relevant entry in the +`bindings` table it built inside its `registerTrigger` callback and invokes each matching function +via `worker.trigger(...)`. + +Continuing the `mini-http` example from above: -The trigger type _id_, the shape of the `config` consumers pass when binding, the call-payload -shape your worker delivers to bound functions, the event ordering guarantees, and any -back-pressure or retry semantics belong in this worker's Worker Docs so consumers know what to -pass when they call `registerTrigger`. Keep iii-level concepts (the trigger binding model itself, -condition gates) here; document the per-type specifics there. + + + ```typescript + // Inside the worker's HTTP listener, after matching method+path to an + // entry in the `bindings` map from the declare-trigger-type example: + const binding = bindings.get(matchedTriggerId); + await worker.trigger({ + function_id: binding.function_id, + payload: { method, headers, body }, + }); + ``` + + + ```python + # Inside the worker's HTTP listener, after matching method+path to an + # entry in the `bindings` dict from the declare-trigger-type example: + binding = bindings[matched_trigger_id] + worker.trigger({ + "function_id": binding.function_id, + "payload": {"method": method, "headers": headers, "body": body}, + }) + ``` + + + ```rust + use iii_sdk::TriggerRequest; + use serde_json::json; + + // Inside the worker's HTTP listener, after matching method+path to an + // entry in the handler's `bindings` map from the declare-trigger-type example: + let binding = handler.bindings.lock().unwrap().get(&matched_trigger_id).cloned(); + if let Some(binding) = binding { + worker + .trigger(TriggerRequest { + function_id: binding.function_id.clone(), + payload: json!({ "method": method, "headers": headers, "body": body }), + action: None, + timeout_ms: None, + }) + .await?; + } + ``` + + + + +On each dispatched event, the engine evaluates the consumer's `config` and optional +`condition_function_id`, then routes matching invocations to the bound function and returns the +result to the caller. diff --git a/docs/creating-workers/triggers.mdx.skill.md b/docs/creating-workers/triggers.mdx.skill.md index 45a2c31af..5ba902d87 100644 --- a/docs/creating-workers/triggers.mdx.skill.md +++ b/docs/creating-workers/triggers.mdx.skill.md @@ -3,195 +3,446 @@ ## What "writing a trigger" means -Workers don't only call functions; they can also _source_ events. When a worker advertises a -trigger type (e.g. `webhook`, a custom schedule, or any external event source you implement), any -other worker can bind its functions to that type. The engine routes binding requests to your -trigger type's handler, and your worker invokes the bound functions when the underlying event -fires. - -This page is about authoring new trigger types from inside your worker. For invoking functions and -binding triggers to functions from the consumer side, see [Using iii / Triggers](/using-iii/triggers). - -## Declare a trigger type - -Inside the worker, register the trigger type once during startup. You pass an `id` and -`description` plus a handler with `registerTrigger` / `unregisterTrigger` callbacks. The engine -calls these whenever a consumer binds or unbinds a function against your trigger type, so your -worker can keep its own table of `{ trigger id → function id, config }` bindings. - -`registerTriggerType` returns a typed handle. Use it later to bind functions you own to this -trigger type, or to tear the type down. +A worker uses triggers two ways. Most of the time, you bind the worker's functions by registering a +trigger on an existing trigger type such as: [`http`](https://workers.iii.dev/workers/iii-http), +[`cron`](https://workers.iii.dev/workers/iii-cron), +[queue messages](https://workers.iii.dev/workers/iii-queue), +[`state` changes](https://workers.iii.dev/workers/iii-state), and any other event source in the +system. Less often, you register a new trigger type from your worker so other workers can bind their +functions to events your worker emits. + +This page primarily covers the latter: making your own triggers, if you want to use existing +triggers in new workers then refer to [Using iii / Triggers](/using-iii/triggers). + + + For the caller-side mechanics (direct invocation with `worker.trigger` or `iii trigger`, the + `TriggerAction` variants, gating with conditions, multiple bindings per function), see [Using iii + / Triggers](/using-iii/triggers). + + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +## Bind a function to an existing trigger type + +Most workers consume trigger types that other workers already publish: `http` from +[iii-http](https://workers.iii.dev/workers/iii-http) to expose a function as an endpoint, `cron` +from [iii-cron](https://workers.iii.dev/workers/iii-cron) to run a function on a schedule, queue +triggers from [iii-queue](https://workers.iii.dev/workers/iii-queue) to fire a function on each +message, `state` from [iii-state](https://workers.iii.dev/workers/iii-state) to react to data +changes. Bind one of your worker's functions to a trigger type with +`worker.registerTrigger({ type, function_id, config })`. The worker that publishes the trigger type +must be connected when you register; otherwise the registration fails. ```typescript - import { registerWorker } from "iii-sdk"; - import type { TriggerConfig, TriggerHandler } from "iii-sdk"; - - const worker = registerWorker(process.env.III_URL); - - type WebhookTriggerConfig = { - path: string; - secret?: string; - }; - - const webhookHandler: TriggerHandler = { - async registerTrigger(config: TriggerConfig) { - // Stash { config.id, config.function_id, config.config } so the - // worker's HTTP listener knows which function to invoke when a - // request matches `config.config.path`. - }, - async unregisterTrigger(config: TriggerConfig) { - // Remove the binding from the worker's table. - }, - }; - - const webhook = worker.registerTriggerType( - { id: "webhook", description: "Incoming webhook trigger" }, - webhookHandler, - ); + worker.registerTrigger({ + type: "http", + function_id: "math::add", + config: { api_path: "/math/add", http_method: "POST" }, + }); ``` ```python - import os - from iii import ( - InitOptions, - RegisterTriggerTypeInput, - TriggerConfig, - TriggerHandler, - register_worker, - ) - - worker = register_worker( - os.environ.get("III_URL"), - InitOptions(worker_name="webhook-worker"), - ) - - class WebhookHandler(TriggerHandler): - async def register_trigger(self, config: TriggerConfig) -> None: - # Stash config.id, config.function_id, config.config - ... - - async def unregister_trigger(self, config: TriggerConfig) -> None: - ... - - webhook = worker.register_trigger_type( - RegisterTriggerTypeInput(id="webhook", description="Incoming webhook trigger"), - WebhookHandler(), - ) + worker.register_trigger({ + "type": "http", + "function_id": "math::add", + "config": {"api_path": "/math/add", "http_method": "POST"}, + }) ``` ```rust - use iii_sdk::{ - InitOptions, RegisterTriggerType, TriggerConfig, TriggerHandler, register_worker, - }; - - let url = std::env::var("III_URL").expect("III_URL must be set"); - let worker = register_worker(&url, InitOptions::default()); - - struct WebhookHandler; - - #[async_trait::async_trait] - impl TriggerHandler for WebhookHandler { - async fn register_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { - // Stash config.id, config.function_id, config.config - Ok(()) - } - - async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { - Ok(()) - } - } + use iii_sdk::RegisterTriggerInput; + use serde_json::json; - let webhook = worker.register_trigger_type( - RegisterTriggerType::new("webhook", "Incoming webhook trigger", WebhookHandler), - ); + worker.register_trigger(RegisterTriggerInput { + trigger_type: "http".into(), + function_id: "math::add".into(), + config: json!({ "api_path": "/math/add", "http_method": "POST" }), + metadata: None, + })?; ``` + -For typed `config` and call-payload schemas, attach Pydantic, Zod, or `schemars::JsonSchema` types -to the registration in your language of choice. See each SDK's reference for the exact builder. +The `config` shape is defined per trigger type and documented in each publishing worker's +[Worker Docs](https://workers.iii.dev). -## Dispatch events to bound functions + + Other binding mechanics are covered in [Using iii / + Triggers](/using-iii/triggers#register-a-trigger): the unregister handle, binding multiple + triggers to the same function, gating with `condition_function_id`, and the `TriggerAction` + variants (`Void`, `Enqueue`, etc.). + -There is no special "fire" API. When the underlying event source delivers something (an incoming -HTTP request, a cron tick, a webhook hit), your worker looks up the bindings it stashed in its -`registerTrigger` callback and invokes each bound function via `worker.trigger(...)`. +## Attach metadata to a trigger + +Each trigger binding accepts an optional `metadata` JSON object set by the consumer at registration +time. The engine stores it as-is and surfaces it in two places: + +1. The publishing worker's `TriggerHandler.registerTrigger(config)` callback sees it as + `config.metadata`, so the publisher can act on consumer-supplied tags (priority hints, audit + labels, routing keys for the publisher's own bookkeeping). +2. [`engine::triggers::list`](/using-iii/functions#engine-functions-engine) returns it on each + `TriggerInfo`, so the console and any other worker doing discovery can read it. ```typescript - // Inside the worker's HTTP listener, after matching a request to a binding: - await worker.trigger({ - function_id: binding.function_id, - payload: { method, headers, body }, + worker.registerTrigger({ + type: "http", + function_id: "math::add", + config: { api_path: "/math/add", http_method: "POST" }, + metadata: { team: "platform", env: "staging" }, }); ``` ```python - # Inside the worker's HTTP listener, after matching a request to a binding: - worker.trigger({ - "function_id": binding["function_id"], - "payload": {"method": method, "headers": headers, "body": body}, + worker.register_trigger({ + "type": "http", + "function_id": "math::add", + "config": {"api_path": "/math/add", "http_method": "POST"}, + "metadata": {"team": "platform", "env": "staging"}, }) ``` ```rust - use iii_sdk::TriggerRequest; + use iii_sdk::RegisterTriggerInput; use serde_json::json; - // Inside the worker's HTTP listener, after matching a request to a binding: - worker - .trigger(TriggerRequest { - function_id: binding.function_id.clone(), - payload: json!({ "method": method, "headers": headers, "body": body }), - action: None, - timeout_ms: None, - }) - .await?; + worker.register_trigger(RegisterTriggerInput { + trigger_type: "http".into(), + function_id: "math::add".into(), + config: json!({ "api_path": "/math/add", "http_method": "POST" }), + metadata: Some(json!({ "team": "platform", "env": "staging" })), + })?; ``` + -On each dispatched event, the engine evaluates the consumer's `config` and optional -`condition_function_id`, then routes matching invocations to the bound function and returns the -result to the caller. - -## Unregister a trigger type - -`registerTriggerType` returns a handle with an `unregister()` method that tears down the trigger -type at runtime. When the worker disconnects, all trigger types it advertised are removed -automatically and the engine stops routing events that depended on them. + + Trigger _types_ have no metadata field of their own. Metadata is attached per binding, not per + type. + +Don't confuse trigger _metadata_ with trigger type [_schemas_](#attach-schemas-to-the-trigger-type) +(`trigger_request_format` and `call_request_format`): + +- **Metadata** is set by the **consumer** on each binding (ie. the `worker.registerTrigger()` call). + It's a free-form tag bag the engine stores as-is, used for the publisher's bookkeeping and for + discovery. For example, a consumer binding to `http` might attach + `metadata: { team: "platform", env: "staging", on_call: "alice" }` so the publishing worker can + log the team, and [`engine::triggers::list`](/using-iii/functions#engine-functions-engine) + surfaces this information on request. +- **Schemas** are set by the **publisher** when declaring the trigger type. They document the JSON + shapes the consumer interacts with. For example, the `http` type published by + [iii-http](https://workers.iii.dev/workers/iii-http) declares: + - `config` (what the consumer passes at bind time): `{ api_path, http_method }`. + - invocation payload (what their bound function receives on each request): + `{ method, headers, query_params, body }`. + + + +## Declaring a Trigger Type + +So far this documentation has focused on being the _consumer_: your worker's functions get bound to +trigger types other workers publish. This section flips the roles. Your worker is now the +_publisher_, and you want functions that other workers register to fire on events your worker +observes (an HTTP request, a webhook hit, a file change, a database update). + +### Components of a Trigger Type + +A trigger type is two things bundled together: + +1. A string `id` that consumers reference when they bind to a trigger (ex. `type: "mini-http"`). +2. A per-binding routing table that **your worker** maintains in-process. The engine's registry + records the binding canonically (this is what + [`engine::triggers::list`](/using-iii/functions#engine-functions-engine) returns), but the engine + doesn't dispatch on it. The engine forwards every bind/unbind from any consumer worker on the + network to the publisher worker as a callback, and this worker decides what to do with each one. + +You declare the trigger type once at startup with +`worker.registerTriggerType({ id, description }, handler)`. + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +The `TriggerHandler` interface **you implement** exposes two callbacks. The engine invokes them on +your publisher worker whenever a consumer binds or unbinds: + +- `registerTrigger(config)`: Runs when any consumer worker binds a function to your trigger type. + The `config` carries the trigger instance's `id`, the consumer's `function_id`, and the + consumer-supplied `config` matching the shape your type accepts. Stash it. +- `unregisterTrigger(config)`: Runs on unbind. Drop it from your table. + +The trigger type can be torn down at any point during runtime with +`worker.unregisterTriggerType(...)` (or `worker.unregister_trigger_type(...)` in Python and Rust). +See the [Unregister a Trigger Type](#unregister-a-trigger-type) section below for per-language +signatures. + +### Example: A mini `iii-http` from scratch + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +The example below sketches a tiny version of [iii-http](https://workers.iii.dev/workers/iii-http), +the worker that publishes the real `http` trigger type. The publisher worker: + +1. Declares an HTTP-shaped trigger type called `mini-http` +1. Maintains a `bindings` map of `{ trigger id → function_id, method+path }` as consumers + bind/unbind +1. Is then ready to look up the right binding when an HTTP request is received. Firing the bound + function is covered in [Dispatch events to bound functions](#dispatch-events-to-bound-functions) + below. + + + + + ```typescript + import { registerWorker } from "iii-sdk"; + import type { TriggerConfig, TriggerHandler } from "iii-sdk"; + + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url); + + type MiniHttpConfig = { + api_path: string; // leading slash, e.g. "/orders" + http_method?: "GET" | "POST" | "PUT" | "DELETE"; + }; + + const bindings = new Map>(); + + const httpHandler: TriggerHandler = { + async registerTrigger(config) { + bindings.set(config.id, config); + }, + async unregisterTrigger(config) { + bindings.delete(config.id); + }, + }; + + worker.registerTriggerType( + { id: "mini-http", description: "Routes HTTP requests to bound functions" }, + httpHandler, + ); + ``` + + + ```python + import os + from iii import ( + InitOptions, + RegisterTriggerTypeInput, + TriggerConfig, + TriggerHandler, + register_worker, + ) + + worker = register_worker( + os.environ.get("III_URL"), + InitOptions(worker_name="mini-http-worker"), + ) + + bindings: dict[str, TriggerConfig] = {} + + class HttpHandler(TriggerHandler): + async def register_trigger(self, config: TriggerConfig) -> None: + bindings[config.id] = config + + async def unregister_trigger(self, config: TriggerConfig) -> None: + bindings.pop(config.id, None) + + worker.register_trigger_type( + RegisterTriggerTypeInput( + id="mini-http", + description="Routes HTTP requests to bound functions", + ), + HttpHandler(), + ) + ``` + + + ```rust + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use iii_sdk::{ + InitOptions, RegisterTriggerType, TriggerConfig, TriggerHandler, register_worker, + }; + + let url = std::env::var("III_URL").expect("III_URL must be set"); + let worker = register_worker(&url, InitOptions::default()); + + #[derive(Default)] + struct HttpHandler { + bindings: Arc>>, + } + + #[async_trait::async_trait] + impl TriggerHandler for HttpHandler { + async fn register_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { + self.bindings.lock().unwrap().insert(config.id.clone(), config); + Ok(()) + } + + async fn unregister_trigger(&self, config: TriggerConfig) -> Result<(), iii_sdk::IIIError> { + self.bindings.lock().unwrap().remove(&config.id); + Ok(()) + } + } + + worker.register_trigger_type( + RegisterTriggerType::new( + "mini-http", + "Routes HTTP requests to bound functions", + HttpHandler::default(), + ), + ); + ``` + + + + + +### Attach schemas to the trigger type + +A trigger type can carry two optional JSON Schemas that describe its payloads: + +- **`trigger_request_format`**: The schema for the per-binding `config` consumers pass to + `worker.registerTrigger(...)` when they bind a function to your trigger type. +- **`call_request_format`**: The schema for the payload your worker delivers to bound functions when + the trigger fires. + +Both feed the iii console, the agent-readable skills, and the +[`engine::trigger-types::list`](/using-iii/functions#engine-functions-engine) output so consumers +know what to send and what they'll receive. + + + Runtime validation is not yet supported. Attached schemas are informational only; the engine does + not reject `config` values or call payloads that don't match them. Treat the schemas as contract + documentation for consumers, agents, and the console; same caveat as [function request / response + schemas](/creating-workers/functions#attach-request-and-response-schemas). + + +Each SDK accepts these in its own idiomatic way: + +| SDK | What you pass | +| --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| [Node](/sdk-reference/node-sdk#registertriggertype) / [Browser](/sdk-reference/browser-sdk#registertriggertype) | Raw JSON Schema objects on `trigger_request_format` / `call_request_format`. Convert Zod 4+ schemas with `z.toJSONSchema(...)`. | +| [Python](/sdk-reference/python-sdk#register_trigger_type) | A Pydantic model class (auto-converted) or a raw dict on the same fields of `RegisterTriggerTypeInput`. | +| [Rust](/sdk-reference/rust-sdk#register_trigger_type) | Builder methods on `RegisterTriggerType`: `.trigger_request_format::()` and `.call_request_format::()`, where `T: schemars::JsonSchema`. | + +{/* TODO: Review against real SDK/CLI surface (now, and post-sdk rework, separately) */} + +### Unregister a Trigger Type + +Tear down a trigger type at runtime when the work it routes is no longer needed. When the worker +disconnects, all trigger types it advertised are removed automatically and the engine stops routing +events that depended on them, so this step is only necessary if you want to drop a type while the +worker stays connected. + +Call it any time after `registerTriggerType` while the worker stays connected (e.g. the underlying +resource went into maintenance mode, a feature flag turned the surface off, or you want to rotate +the type to a new schema without restarting). Continuing the `mini-http` example, here the worker +drops `mini-http` because its HTTP listener was disabled by config: ```typescript - webhook.unregister(); + // e.g. config reload disabled the HTTP listener; stop accepting new bindings + // while the worker keeps serving other trigger types. + worker.unregisterTriggerType({ + id: "mini-http", + description: "Routes HTTP requests to bound functions", + }); ``` ```python + # e.g. config reload disabled the HTTP listener; stop accepting new bindings + # while the worker keeps serving other trigger types. worker.unregister_trigger_type( - RegisterTriggerTypeInput(id="webhook", description="Incoming webhook trigger"), + {"id": "mini-http", "description": "Routes HTTP requests to bound functions"} ) ``` ```rust - worker.unregister_trigger_type("webhook"); + // e.g. config reload disabled the HTTP listener; stop accepting new bindings + // while the worker keeps serving other trigger types. + worker.unregister_trigger_type("mini-http"); ``` -## What goes in Worker Docs +{/* TODO: Update once this inconsistency is fixed */} -The trigger type _id_, the shape of the `config` consumers pass when binding, the call-payload -shape your worker delivers to bound functions, the event ordering guarantees, and any -back-pressure or retry semantics belong in this worker's Worker Docs so consumers know what to -pass when they call `registerTrigger`. Keep iii-level concepts (the trigger binding model itself, -condition gates) here; document the per-type specifics there. + + Node's `registerTriggerType` also returns a `TriggerTypeRef` with an `.unregister()` shortcut that + delegates to `worker.unregisterTriggerType(...)`. Python's `TriggerTypeRef` only exposes + `register_trigger` and `register_function`; tear down the trigger type itself via + `worker.unregister_trigger_type(...)`. Rust takes only the `id` string; Node and Python take the + full input object but only the `id` field is used to identify the type being torn down. + + +## Dispatch events to bound functions + +There is no special "fire" API. When the underlying event source delivers something (an incoming +HTTP request, a cron tick, a webhook hit), your publisher worker looks up the relevant entry in the +`bindings` table it built inside its `registerTrigger` callback and invokes each matching function +via `worker.trigger(...)`. + +Continuing the `mini-http` example from above: + + + + ```typescript + // Inside the worker's HTTP listener, after matching method+path to an + // entry in the `bindings` map from the declare-trigger-type example: + const binding = bindings.get(matchedTriggerId); + await worker.trigger({ + function_id: binding.function_id, + payload: { method, headers, body }, + }); + ``` + + + ```python + # Inside the worker's HTTP listener, after matching method+path to an + # entry in the `bindings` dict from the declare-trigger-type example: + binding = bindings[matched_trigger_id] + worker.trigger({ + "function_id": binding.function_id, + "payload": {"method": method, "headers": headers, "body": body}, + }) + ``` + + + ```rust + use iii_sdk::TriggerRequest; + use serde_json::json; + + // Inside the worker's HTTP listener, after matching method+path to an + // entry in the handler's `bindings` map from the declare-trigger-type example: + let binding = handler.bindings.lock().unwrap().get(&matched_trigger_id).cloned(); + if let Some(binding) = binding { + worker + .trigger(TriggerRequest { + function_id: binding.function_id.clone(), + payload: json!({ "method": method, "headers": headers, "body": body }), + action: None, + timeout_ms: None, + }) + .await?; + } + ``` + + + + +On each dispatched event, the engine evaluates the consumer's `config` and optional +`condition_function_id`, then routes matching invocations to the bound function and returns the +result to the caller. diff --git a/docs/creating-workers/workers.mdx b/docs/creating-workers/workers.mdx index 72305cb34..68264129f 100644 --- a/docs/creating-workers/workers.mdx +++ b/docs/creating-workers/workers.mdx @@ -1,6 +1,6 @@ --- title: "Workers" -description: "Deploying and integrating workers into an iii project." +description: "Deploying and integrating workers into a iii project." owner: "devrel" type: "how-to" --- @@ -8,23 +8,67 @@ type: "how-to" ## How workers expand iii Workers add capability to an iii system. Each one contributes functions and triggers the engine can -route to. This page covers deploying and wiring workers into a project. For the authoring surface a -worker uses to register its functions and triggers (the SDK), see each language's Worker Docs -authoring guide. +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)). + + + For the full SDK surface each Worker can use when interacting with iii, see the complete SDK + reference by language: [Node](/sdk-reference/node-sdk), [Python](/sdk-reference/python-sdk), + [Rust](/sdk-reference/rust-sdk), or [Browser](/sdk-reference/browser-sdk). + + +## Scaffold a new worker + +`iii worker init` creates a new standalone worker from scratch. The command writes a +language-specific project directory with the iii SDK installed, an `iii.worker.yaml` manifest, and +example function and trigger registrations you can replace with your own. + +```bash +# Interactive: prompts for the language +iii worker init my-worker + +# Fully scripted: pass --language to skip the prompt +iii worker init my-worker --language typescript +``` + +Supported languages: `typescript` (`ts`), `javascript` (`js`), `python` (`py`), `rust` (`rs`). + +The positional `NAME` is the target directory; pass `--directory` to override it. + +Re-running `iii worker init` on a directory that already holds an iii worker (ie. has +`.iii/worker.ini`) will make no changes to the worker. + +Worker init will fail by default when targeting a non-empty directory, use `--allow-non-empty` to +scaffold into any other non-empty directory. + + + To install an existing worker from the registry instead of scaffolding a new one, use `iii worker + add`. See [Using iii / Workers](/using-iii/workers#finding-workers) for the registry surface. + ## Connecting to the engine -A worker connects to the engine over WebSocket. Set the engine URL via the `III_URL` environment -variable, or pass it 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. +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. ```typescript import { registerWorker } from "iii-sdk"; - const worker = registerWorker(process.env.III_URL); + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url, { + workerName: "my-worker", + }); ``` @@ -42,16 +86,27 @@ reachable on the network. ```rust - use iii_sdk::{InitOptions, register_worker}; + use iii_sdk::{InitOptions, WorkerMetadata, register_worker}; let url = std::env::var("III_URL").expect("III_URL must be set"); - let worker = register_worker(&url, InitOptions::default()); + let worker = register_worker( + &url, + InitOptions { + metadata: Some(WorkerMetadata { + name: "my-worker".into(), + ..Default::default() + }), + ..Default::default() + }, + ); ``` -## Worker lifecycle states +## Worker lifecycle + +### States Workers transition through a small set of states after connecting: `connecting → connected → available / busy → disconnected`. `connecting` is the WebSocket handshake. @@ -60,44 +115,328 @@ whether the Worker is currently handling invocations. `disconnected` is the term 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. -## Handling Worker disconnects +### 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 registered Trigger. Filterable by `include_internal`. | +| `engine::trigger-types::list` | Every advertised Trigger type with its config and call schemas. | + + + + + ```typescript + // engine::workers::list, pass { worker_id: "" } 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::trigger-types::list + const { trigger_types } = await worker.trigger({ + function_id: "engine::trigger-types::list", + payload: { include_internal: false }, + }); + ``` + + + ```python + # engine::workers::list, pass {"worker_id": ""} 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::trigger-types::list + trigger_types = worker.trigger({ + "function_id": "engine::trigger-types::list", + "payload": {"include_internal": False}, + })["trigger_types"] + ``` + + + ```rust + use iii_sdk::TriggerRequest; + use serde_json::json; + + // engine::workers::list, pass json!({ "worker_id": "" }) 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::trigger-types::list + let trigger_types = worker + .trigger(TriggerRequest { + function_id: "engine::trigger-types::list".into(), + payload: json!({ "include_internal": false }), + action: None, + timeout_ms: None, + }) + .await?; + ``` + + + + + +### 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. -There are two things to handle on the caller side: - -1. **Catch `invocation_stopped`.** Callers waiting on a Function whose Worker disconnects mid-flight - receive an `invocation_stopped` error rather than a timeout. Treat it like a cancellation, not a - transient failure. Retrying will fail until a Worker reconnects and re-registers the Function. -1. **Subscribe to the discovery events** if you need to react to topology changes: - - `engine::workers-available` fires immediately when a Worker connects or disconnects. - - `engine::functions-available` is eventually consistent; it fires on the next polling tick once - the function-list hash changes. - -## Inspecting the live registry - -To see what's currently connected to the Engine, use one of two surfaces depending on whether you -want a snapshot or a live subscription: - -- **Read a snapshot** by invoking one of the `engine::*::list` Functions: - - `engine::workers::list`: every connected Worker with metrics. - - `engine::functions::list`: every registered Function (filterable by `include_internal`). - - `engine::triggers::list`: every registered Trigger (filterable by `include_internal`). - - `engine::trigger-types::list`: every advertised Trigger type with its config and call schemas. -- **Subscribe to changes** by registering a Trigger against `engine::workers-available` (fires when - a Worker connects or disconnects) or `engine::functions-available` (fires when a Function is - registered or unregistered). See [Handling Worker disconnects](#handling-worker-disconnects) for - their consistency semantics. - - These are the high-level call surfaces. For the wire-level shapes, see [Engine - protocol](/sdk-reference/engine-sdk#engine-discovery-functions). - +#### 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. + +{/* TODO: Revisit and check code sample after SDK surface is reworked */} + + + + + ```typescript + import { IIIInvocationError } from "iii-sdk"; + + try { + const result = await worker.trigger({ + function_id: "math::add", + payload: { a: 1, b: 2 }, + }); + } catch (err) { + if (err instanceof IIIInvocationError && 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; + } + ``` + + + ```python + from iii import IIIInvocationError + + try: + result = worker.trigger({ + "function_id": "math::add", + "payload": {"a": 1, "b": 2}, + }) + except IIIInvocationError 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 + ``` + + + ```rust + use iii_sdk::{IIIError, 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(IIIError::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 */ } + } + ``` + + + + + +#### Subscribe to changes + +{/* TODO: Link out to SDK reference once SDK surface is stable */} + +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. | + + + + + ```typescript + worker.registerFunction( + "discovery::on-workers", + async (data: { event: string; worker_id: string }) => { + if (data.event === "worker_connected") { + // A Worker just 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: {}, + }); + ``` + + + ```python + async def on_workers(data: dict) -> None: + if data["event"] == "worker_connected": + # A Worker just 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": {}, + }) + ``` + + + ```rust + use iii_sdk::{RegisterFunction, 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 } + + worker.register_function(RegisterFunction::new_async( + "discovery::on-workers", + |input: WorkersAvailable| async move { + if input.event == "worker_connected" { + // A Worker just joined the registry; its Functions are callable now. + } + Ok::<_, String>(()) + }, + )); + worker.register_trigger(RegisterTriggerInput { + trigger_type: "engine::workers-available".into(), + function_id: "discovery::on-workers".into(), + config: json!({}), + metadata: None, + })?; + + worker.register_function(RegisterFunction::new_async( + "discovery::on-functions", + |input: FunctionsAvailable| async move { + // `functions` is the full snapshot after the change. + let _count = input.functions.len(); + Ok::<_, String>(()) + }, + )); + worker.register_trigger(RegisterTriggerInput { + trigger_type: "engine::functions-available".into(), + function_id: "discovery::on-functions".into(), + config: json!({}), + metadata: None, + })?; + ``` + + + + ## Worker manifest -When a worker is checked into a project so iii can launch it locally, `iii.worker.yaml` at the -worker's root tells iii how to install dependencies, run the worker, and pass through configuration. +`iii.worker.yaml` is the manifest at the worker's root that tells iii how to install dependencies, +run the worker, and pass through configuration. This applies to both the iii worker CLI commands +(e.g. [`start`, `stop`, `restart`](/using-iii/workers#starting-and-stopping-workers)) and to workers +that iii starts automatically when they're specified in iii's +[`config.yaml`](/using-iii/engine#configuration-file-structure). ```yaml name: math-worker @@ -110,30 +449,60 @@ scripts: start: "python math_worker.py" ``` -The manifest is metadata about _starting_ the Worker. Once the Worker is running, the WebSocket -connection to the Engine and the function registrations are what matter. A Worker started by -`iii worker add` and a Worker started by hand in a container behave identically to the Engine. +The manifest is metadata about _starting_ the Worker. Once the Worker is running iii treats them all +the same. A Worker started by `iii` via its `config.yaml`, via `iii worker start`, or a manually run +process that uses the iii SDK all behave identically with the Engine. -For the full manifest field schema, see [Using iii / Workers](/using-iii/workers). + + If a worker isn't starting correctly then make sure to check its manifest and [`iii worker + logs`](/using-iii/workers#inspecting-a-worker). + -## What a worker contributes +{/* TODO: link to the canonical iii.worker.yaml reference page once it exists. */} -Once connected, a worker exposes: +## Shutting down a worker -- 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)). +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`. -The SDK calls a worker uses to register these are language-specific and documented in each -language's Worker Docs authoring guide. +Without `shutdown`, an abrupt process exit reaches the same state once the engine notices the +dropped socket; graceful shutdown makes it deterministic and faster. -## Run an ephemeral worker + + + ```typescript + process.on("SIGTERM", async () => { + await worker.shutdown(); + process.exit(0); + }); + ``` + + + ```python + import signal -For one-shot jobs (Kubernetes Jobs, serverless containers, scheduled scripts), an SDK worker can -connect to a remote engine, register its functions, do the work, and exit. The engine cleans up the -worker's registrations on disconnect. + def _on_term(*_): + worker.shutdown() + raise SystemExit(0) + + signal.signal(signal.SIGTERM, _on_term) + ``` + + + + ```rust + // 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; + ``` + + + + + Shutdown is very useful for **One-shot / ephemeral workers**. Kubernetes Jobs, serverless + containers, or scheduled scripts can connect just like any other Worker, do their work, and + `shutdown()` (`shutdown_async().await` in Rust). + -Set `III_URL` to point the worker at the remote engine (see -[Connecting to the engine](#connecting-to-the-engine)), register the work the job needs to expose, -and let the process exit when the job is done. +{/* TODO: confirm the SDK shutdown call (e.g. `worker.shutdown()`) and add a minimal Node / TypeScript, Python, Rust example that registers a function, awaits a single invocation, and exits cleanly. */} diff --git a/docs/creating-workers/workers.mdx.skill.md b/docs/creating-workers/workers.mdx.skill.md index 33cc03769..5ffee5dcb 100644 --- a/docs/creating-workers/workers.mdx.skill.md +++ b/docs/creating-workers/workers.mdx.skill.md @@ -1,28 +1,70 @@ -# Workers - ## How workers expand iii Workers add capability to an iii system. Each one contributes functions and triggers the engine can -route to. This page covers deploying and wiring workers into a project. For the authoring surface a -worker uses to register its functions and triggers (the SDK), see each language's Worker Docs -authoring guide. +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)). + + + For the full SDK surface each Worker can use when interacting with iii, see the complete SDK + reference by language: [Node](/sdk-reference/node-sdk), [Python](/sdk-reference/python-sdk), + [Rust](/sdk-reference/rust-sdk), or [Browser](/sdk-reference/browser-sdk). + + +## Scaffold a new worker + +`iii worker init` creates a new standalone worker from scratch. The command writes a +language-specific project directory with the iii SDK installed, an `iii.worker.yaml` manifest, and +example function and trigger registrations you can replace with your own. + +```bash +# Interactive: prompts for the language +iii worker init my-worker + +# Fully scripted: pass --language to skip the prompt +iii worker init my-worker --language typescript +``` + +Supported languages: `typescript` (`ts`), `javascript` (`js`), `python` (`py`), `rust` (`rs`). + +The positional `NAME` is the target directory; pass `--directory` to override it. + +Re-running `iii worker init` on a directory that already holds an iii worker (ie. has +`.iii/worker.ini`) will make no changes to the worker. + +Worker init will fail by default when targeting a non-empty directory, use `--allow-non-empty` to +scaffold into any other non-empty directory. + + + To install an existing worker from the registry instead of scaffolding a new one, use `iii worker + add`. See [Using iii / Workers](/using-iii/workers#finding-workers) for the registry surface. + ## Connecting to the engine -A worker connects to the engine over WebSocket. Set the engine URL via the `III_URL` environment -variable, or pass it 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. +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. ```typescript import { registerWorker } from "iii-sdk"; - const worker = registerWorker(process.env.III_URL); + const url = process.env.III_URL; + if (!url) throw new Error("III_URL must be set"); + const worker = registerWorker(url, { + workerName: "my-worker", + }); ``` @@ -40,16 +82,27 @@ reachable on the network. ```rust - use iii_sdk::{InitOptions, register_worker}; + use iii_sdk::{InitOptions, WorkerMetadata, register_worker}; let url = std::env::var("III_URL").expect("III_URL must be set"); - let worker = register_worker(&url, InitOptions::default()); + let worker = register_worker( + &url, + InitOptions { + metadata: Some(WorkerMetadata { + name: "my-worker".into(), + ..Default::default() + }), + ..Default::default() + }, + ); ``` -## Worker lifecycle states +## Worker lifecycle + +### States Workers transition through a small set of states after connecting: `connecting → connected → available / busy → disconnected`. `connecting` is the WebSocket handshake. @@ -58,44 +111,328 @@ whether the Worker is currently handling invocations. `disconnected` is the term 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. -## Handling Worker disconnects +### 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 registered Trigger. Filterable by `include_internal`. | +| `engine::trigger-types::list` | Every advertised Trigger type with its config and call schemas. | + + + + + ```typescript + // engine::workers::list, pass { worker_id: "" } 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::trigger-types::list + const { trigger_types } = await worker.trigger({ + function_id: "engine::trigger-types::list", + payload: { include_internal: false }, + }); + ``` + + + ```python + # engine::workers::list, pass {"worker_id": ""} 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::trigger-types::list + trigger_types = worker.trigger({ + "function_id": "engine::trigger-types::list", + "payload": {"include_internal": False}, + })["trigger_types"] + ``` + + + ```rust + use iii_sdk::TriggerRequest; + use serde_json::json; + + // engine::workers::list, pass json!({ "worker_id": "" }) 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::trigger-types::list + let trigger_types = worker + .trigger(TriggerRequest { + function_id: "engine::trigger-types::list".into(), + payload: json!({ "include_internal": false }), + action: None, + timeout_ms: None, + }) + .await?; + ``` + + + + + +### 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. -There are two things to handle on the caller side: - -1. **Catch `invocation_stopped`.** Callers waiting on a Function whose Worker disconnects mid-flight - receive an `invocation_stopped` error rather than a timeout. Treat it like a cancellation, not a - transient failure. Retrying will fail until a Worker reconnects and re-registers the Function. -1. **Subscribe to the discovery events** if you need to react to topology changes: - - `engine::workers-available` fires immediately when a Worker connects or disconnects. - - `engine::functions-available` is eventually consistent; it fires on the next polling tick once - the function-list hash changes. - -## Inspecting the live registry - -To see what's currently connected to the Engine, use one of two surfaces depending on whether you -want a snapshot or a live subscription: - -- **Read a snapshot** by invoking one of the `engine::*::list` Functions: - - `engine::workers::list`: every connected Worker with metrics. - - `engine::functions::list`: every registered Function (filterable by `include_internal`). - - `engine::triggers::list`: every registered Trigger (filterable by `include_internal`). - - `engine::trigger-types::list`: every advertised Trigger type with its config and call schemas. -- **Subscribe to changes** by registering a Trigger against `engine::workers-available` (fires when - a Worker connects or disconnects) or `engine::functions-available` (fires when a Function is - registered or unregistered). See [Handling Worker disconnects](#handling-worker-disconnects) for - their consistency semantics. - - These are the high-level call surfaces. For the wire-level shapes, see [Engine - protocol](/sdk-reference/engine-sdk#engine-discovery-functions). - +#### 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. + +{/* TODO: Revisit and check code sample after SDK surface is reworked */} + + + + + ```typescript + import { IIIInvocationError } from "iii-sdk"; + + try { + const result = await worker.trigger({ + function_id: "math::add", + payload: { a: 1, b: 2 }, + }); + } catch (err) { + if (err instanceof IIIInvocationError && 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; + } + ``` + + + ```python + from iii import IIIInvocationError + + try: + result = worker.trigger({ + "function_id": "math::add", + "payload": {"a": 1, "b": 2}, + }) + except IIIInvocationError 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 + ``` + + + ```rust + use iii_sdk::{IIIError, 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(IIIError::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 */ } + } + ``` + + + + + +#### Subscribe to changes + +{/* TODO: Link out to SDK reference once SDK surface is stable */} + +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. | + + + + + ```typescript + worker.registerFunction( + "discovery::on-workers", + async (data: { event: string; worker_id: string }) => { + if (data.event === "worker_connected") { + // A Worker just 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: {}, + }); + ``` + + + ```python + async def on_workers(data: dict) -> None: + if data["event"] == "worker_connected": + # A Worker just 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": {}, + }) + ``` + + + ```rust + use iii_sdk::{RegisterFunction, 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 } + + worker.register_function(RegisterFunction::new_async( + "discovery::on-workers", + |input: WorkersAvailable| async move { + if input.event == "worker_connected" { + // A Worker just joined the registry; its Functions are callable now. + } + Ok::<_, String>(()) + }, + )); + worker.register_trigger(RegisterTriggerInput { + trigger_type: "engine::workers-available".into(), + function_id: "discovery::on-workers".into(), + config: json!({}), + metadata: None, + })?; + + worker.register_function(RegisterFunction::new_async( + "discovery::on-functions", + |input: FunctionsAvailable| async move { + // `functions` is the full snapshot after the change. + let _count = input.functions.len(); + Ok::<_, String>(()) + }, + )); + worker.register_trigger(RegisterTriggerInput { + trigger_type: "engine::functions-available".into(), + function_id: "discovery::on-functions".into(), + config: json!({}), + metadata: None, + })?; + ``` + + + + ## Worker manifest -When a worker is checked into a project so iii can launch it locally, `iii.worker.yaml` at the -worker's root tells iii how to install dependencies, run the worker, and pass through configuration. +`iii.worker.yaml` is the manifest at the worker's root that tells iii how to install dependencies, +run the worker, and pass through configuration. This applies to both the iii worker CLI commands +(e.g. [`start`, `stop`, `restart`](/using-iii/workers#starting-and-stopping-workers)) and to workers +that iii starts automatically when they're specified in iii's +[`config.yaml`](/using-iii/engine#configuration-file-structure). ```yaml name: math-worker @@ -108,30 +445,60 @@ scripts: start: "python math_worker.py" ``` -The manifest is metadata about _starting_ the Worker. Once the Worker is running, the WebSocket -connection to the Engine and the function registrations are what matter. A Worker started by -`iii worker add` and a Worker started by hand in a container behave identically to the Engine. +The manifest is metadata about _starting_ the Worker. Once the Worker is running iii treats them all +the same. A Worker started by `iii` via its `config.yaml`, via `iii worker start`, or a manually run +process that uses the iii SDK all behave identically with the Engine. -For the full manifest field schema, see [Using iii / Workers](/using-iii/workers). + + If a worker isn't starting correctly then make sure to check its manifest and [`iii worker + logs`](/using-iii/workers#inspecting-a-worker). + -## What a worker contributes +{/* TODO: link to the canonical iii.worker.yaml reference page once it exists. */} -Once connected, a worker exposes: +## Shutting down a worker -- 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)). +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. + + + + ```typescript + process.on("SIGTERM", async () => { + await worker.shutdown(); + process.exit(0); + }); + ``` + + + ```python + import signal -The SDK calls a worker uses to register these are language-specific and documented in each -language's Worker Docs authoring guide. + def _on_term(*_): + worker.shutdown() + raise SystemExit(0) -## Run an ephemeral worker + signal.signal(signal.SIGTERM, _on_term) + ``` -For one-shot jobs (Kubernetes Jobs, serverless containers, scheduled scripts), an SDK worker can -connect to a remote engine, register its functions, do the work, and exit. The engine cleans up the -worker's registrations on disconnect. + + + ```rust + // 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; + ``` + + + + + Shutdown is very useful for **One-shot / ephemeral workers**. Kubernetes Jobs, serverless + containers, or scheduled scripts can connect just like any other Worker, do their work, and + `shutdown()` (`shutdown_async().await` in Rust). + -Set `III_URL` to point the worker at the remote engine (see -[Connecting to the engine](#connecting-to-the-engine)), register the work the job needs to expose, -and let the process exit when the job is done. +{/* TODO: confirm the SDK shutdown call (e.g. `worker.shutdown()`) and add a minimal Node / TypeScript, Python, Rust example that registers a function, awaits a single invocation, and exits cleanly. */} diff --git a/docs/custom.css b/docs/custom.css index d3afd760f..b362fff02 100644 --- a/docs/custom.css +++ b/docs/custom.css @@ -175,6 +175,14 @@ pre code { color: inherit !important; } +pre, +pre code, +.code-block [data-line], +.code-block .line { + font-variant-numeric: tabular-nums; + line-height: 1.45 !important; +} + .code-block { background: var(--iii-panel) !important; border: 1px solid var(--iii-rule) !important; @@ -183,7 +191,7 @@ pre code { .code-block [data-component-part="code-block-root"] { background: transparent !important; - padding: 14px 16px !important; + padding: 10px 14px !important; } .code-block [data-component-part="code-block-root"] > pre { @@ -339,3 +347,41 @@ a:has(> span.bg-primary-dark):hover svg { [data-fade-overlay] { display: none !important; } + +/* Match accordion borders to table borders. Container is transparent so its + children control fill: trigger/header always uses inline-code panel bg, + expanded content uses the page bg. */ +details, +[data-component-part="accordion"], +[data-component-part="accordion-group"] > * { + background-color: transparent !important; + border: 1px solid var(--iii-rule-2) !important; + box-shadow: none !important; + color: var(--iii-ink) !important; +} + +details > summary, +[data-component-part="accordion"] > [data-component-part="accordion-trigger"], +[data-component-part="accordion"] > button:first-child { + background-color: var(--iii-panel) !important; + color: var(--iii-ink) !important; +} + +details[open] > summary, +[data-component-part="accordion"][data-state="open"] > [data-component-part="accordion-trigger"], +[data-component-part="accordion"][data-state="open"] > button:first-child { + border-bottom: 1px solid var(--iii-rule-2) !important; +} + +details > *:not(summary), +[data-component-part="accordion-content"] { + background-color: var(--iii-bg) !important; +} + +[data-component-part="accordion-group"] { + border: 0 !important; +} + +[data-component-part="accordion-group"] > * + * { + border-top: 0 !important; +} diff --git a/docs/docs.json b/docs/docs.json index 5f301ba6c..2df83968d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -253,7 +253,6 @@ "using-iii/workers-registry", "using-iii/triggers", "using-iii/functions", - "using-iii/channels", "using-iii/engine", "using-iii/console", "using-iii/cli", @@ -265,19 +264,18 @@ "expanded": false, "pages": [ "understanding-iii/index", - "understanding-iii/workers", - "understanding-iii/functions", - "understanding-iii/triggers", - "understanding-iii/channels", - "understanding-iii/engine" + "understanding-iii/engine", + "understanding-iii/channels" ] }, { "group": "Creating Workers", "pages": [ + "creating-workers/index", "creating-workers/workers", + "creating-workers/triggers", "creating-workers/functions", - "creating-workers/triggers" + "creating-workers/channels" ] } ] diff --git a/docs/how-to/schedule-cron-task.mdx b/docs/how-to/schedule-cron-task.mdx new file mode 100644 index 000000000..be46b77b9 --- /dev/null +++ b/docs/how-to/schedule-cron-task.mdx @@ -0,0 +1,8 @@ +--- +title: "Schedule a cron task" +description: "Run a function on a recurring schedule with the iii-cron trigger type." +owner: "devrel" +type: "how-to" +--- + +{/* TODO: write the how-to. Cover installing/enabling iii-cron, registering a function, binding it to a `cron` trigger with a schedule expression, verifying it fires, common schedule strings, timezone behaviour, what happens on overlap (skip vs queue), and how to manually invoke for testing. */} diff --git a/docs/how-to/schedule-cron-task.mdx.skill.md b/docs/how-to/schedule-cron-task.mdx.skill.md new file mode 100644 index 000000000..ac5116f23 --- /dev/null +++ b/docs/how-to/schedule-cron-task.mdx.skill.md @@ -0,0 +1,6 @@ + + +# Schedule a cron task + + +{/* TODO: write the how-to. Cover installing/enabling iii-cron, registering a function, binding it to a `cron` trigger with a schedule expression, verifying it fires, common schedule strings, timezone behaviour, what happens on overlap (skip vs queue), and how to manually invoke for testing. */} diff --git a/docs/how-to/use-trigger-conditions.mdx b/docs/how-to/use-trigger-conditions.mdx new file mode 100644 index 000000000..f9e3573db --- /dev/null +++ b/docs/how-to/use-trigger-conditions.mdx @@ -0,0 +1,8 @@ +--- +title: "Gate a trigger with a condition" +description: "Run a small function before a trigger handler fires and skip the invocation when the condition returns falsey." +owner: "devrel" +type: "how-to" +--- + +{/* TODO: write the how-to. Cover registering a condition function (returns truthy/falsey from the same payload the handler would see), registering the trigger with `condition_function_id` set, verifying the handler runs only when the condition passes. Show one realistic example (e.g. HTTP trigger gated by an auth check) and call out when to prefer this over branching inside the handler. */} diff --git a/docs/how-to/use-trigger-conditions.mdx.skill.md b/docs/how-to/use-trigger-conditions.mdx.skill.md new file mode 100644 index 000000000..3d5451ae9 --- /dev/null +++ b/docs/how-to/use-trigger-conditions.mdx.skill.md @@ -0,0 +1,6 @@ + + +# Gate a trigger with a condition + + +{/* TODO: write the how-to. Cover registering a condition function (returns truthy/falsey from the same payload the handler would see), registering the trigger with `condition_function_id` set, verifying the handler runs only when the condition passes. Show one realistic example (e.g. HTTP trigger gated by an auth check) and call out when to prefer this over branching inside the handler. */} diff --git a/docs/index.mdx b/docs/index.mdx index 848be1f0d..6cb0d25ea 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -24,20 +24,12 @@ stack. ## The Solution -iii organizes software into three primitives: **Worker**, **Trigger**, **Function**. Something hosts -work, something causes it, something does it. Every capability in every system can be built from -these three things: queues, cron, streaming, sandboxing, observability, agents, business logic, -devices, even frontend browser UIs. +iii reduces that quadratic effort to zero. Adding two workers or two hundred is the same operation. -## Quadratic to Zero - -The integration problem doesn't only manifest where two systems connect. It creates downstream -friction in logging, debugging, and upgrading, and upstream friction that blocks growth initiatives, -new verticals, and partnerships. - -iii reduces all of that effort to zero. Adding two workers or two hundred is the same operation. A -new worker joins the system by opening a single WebSocket connection and becomes immediately -available to every other worker at that moment. +iii accomplishes this by organizing software into three primitives: **Worker**, **Trigger**, +**Function**. Something hosts work, something causes it, something does it. Every capability in +every system can be built from these three things: queues, cron, streaming, sandboxing, +observability, agents, business logic, devices, even frontend browser UIs. ## Have a Need? Add a Worker diff --git a/docs/index.mdx.skill.md b/docs/index.mdx.skill.md index cc83bafca..78ecc6ba6 100644 --- a/docs/index.mdx.skill.md +++ b/docs/index.mdx.skill.md @@ -20,20 +20,12 @@ stack. ## The Solution -iii organizes software into three primitives: **Worker**, **Trigger**, **Function**. Something hosts -work, something causes it, something does it. Every capability in every system can be built from -these three things: queues, cron, streaming, sandboxing, observability, agents, business logic, -devices, even frontend browser UIs. +iii reduces that quadratic effort to zero. Adding two workers or two hundred is the same operation. -## Quadratic to Zero - -The integration problem doesn't only manifest where two systems connect. It creates downstream -friction in logging, debugging, and upgrading, and upstream friction that blocks growth initiatives, -new verticals, and partnerships. - -iii reduces all of that effort to zero. Adding two workers or two hundred is the same operation. A -new worker joins the system by opening a single WebSocket connection and becomes immediately -available to every other worker at that moment. +iii accomplishes this by organizing software into three primitives: **Worker**, **Trigger**, +**Function**. Something hosts work, something causes it, something does it. Every capability in +every system can be built from these three things: queues, cron, streaming, sandboxing, +observability, agents, business logic, devices, even frontend browser UIs. ## Have a Need? Add a Worker diff --git a/docs/install.mdx b/docs/install.mdx index 9b040dc6a..e6cf1a09c 100644 --- a/docs/install.mdx +++ b/docs/install.mdx @@ -30,7 +30,7 @@ iii --version otherwise. -{/* TODO: re-enable the "## 3. Install the VS Code Extension (Optional)" section once the iii-lsp extension is more thoroughly tested across VS Code, Cursor, Windsurf, and VSCodium. The Frame demo also needs `/images/lsp.mp4` to be captured and committed before the section is re-added. ## 3. Install the VS Code Extension (Optional) The iii Language Server extension gives your editor live awareness of your iii project: - **Completions**: function IDs, trigger types, payload properties, and known values like stream names and API paths. - **Hover documentation**: request and response schemas for any registered function. - **Diagnostics**: validates function IDs, required payload fields, trigger configs, cron expressions, and HTTP methods as you type. Works with TypeScript, Python, and Rust files. Requires a running iii engine.