Skip to main content
October 2026

Observe: every function is observable

  • iii.observe(source, target, options?) creates an observer: every invocation of the source function that passes its filters invokes the target, a registered function. It is available in the Node, browser, Python, Rust, and Go SDKs. Any worker can observe any function it may invoke, and the observed function’s worker needs no code for it.
  • The source chooses what the target receives: on (success, error, or complete), capture (input, output, error, caller, metadata), and deliver (the Observed envelope, or the observed input or output alone). Options set the observer’s id, max_fires, expires_in_ms, max_in_flight, and metadata, which reaches the target as the activation’s metadata sidecar.
  • observe returns a handle at once. It reports the observer’s state (pending, active, suspended, error, disposed), resolves ready() once the observer is active, and replays the observer with the same id after a reconnect. An observer outlives a disconnect of its worker for 30 seconds by default.
  • Every invocation carries a caller: the calling worker, stamped by the engine, and the function and invocation it was made from, with its own caller as caller.parent. Filters can read it, and iii.currentInvocation() returns it inside any handler.
  • Every invocation also carries an observe depth. Activations deeper than 8 hops are dropped as LOOP_GUARD, and an observer whose target is the observed function is rejected with SELF_TARGET.
See Observers.

Observable functions

  • registerFunction(id, handler, { observe }) declares how a function is observed. Hooks (onObserverAdded, onObserverRemoved) run when an observer is added or removed, before the engine marks it active; throwing rejects the observer with your own code.
  • With delivery: "owner", the function’s worker routes events itself: it reads each observer’s args, validated by the engine against args_format, and hands each event to the observers it belongs to with obs.emit(event) (fire-and-forget) or obs.call(payload) (request/response, returning the target’s result). The SDK applies each observer’s filters in both.
  • legacy_trigger_type lets a function stand in for a trigger type: the engine turns old registerTrigger calls of that type into observers of the function, so existing consumers keep working while the provider moves to a function.
  • observe: { allow: false } refuses every observer (NOT_OBSERVABLE). FunctionRef.isObserved() and observers() report the function’s active observers.
See Observable functions.

Filters

  • Observer filters are declarative JSON objects over input, output, error, caller, and metadata, with explicit operators only: $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte, $exists, $prefix, $any, $and, $or, and $not.
  • The new iii-filter crate is the evaluator of the engine and the Rust SDK. The Node, browser, Python, and Go SDKs ship evaluators with the same semantics, and every implementation runs the shared conformance vectors in sdk/fixtures/filters.
  • The engine validates filters when an observer is created (INVALID_FILTER) and checks their paths against the function’s request_format and response_format (INVALID_FILTER_PATH). Limits: depth 8, 64 operators, 256 items per $in / $nin, 16 path segments, and 8 KiB.
See Filter language.

Local dispatch

  • A worker that calls a function it registered itself runs the handler in-process once the engine confirmed the registration. An unobserved local call puts nothing on the wire; when observers match, the SDK reports the call to the engine in batches.
  • Turn it off per function (localDispatch: false), per call (dispatch: "remote"), or for the engine (observers.local_dispatch: false). It is off for sessions with an RBAC middleware_function_id.
Local calls skip the message size limit and the engine’s call span and metrics; their counts reach iii.invocations.total with dispatch="local" every 10 seconds. In Rust, a sync handler called locally runs inline on the caller’s task. See Upgrading from 0.24.x.

Engine

  • A new top-level observers: section in config.yaml (and engine.observers in worker-compose.yaml) sets local_dispatch, per-connection and per-function limits, max_observe_depth, the observer grace period, the owner acknowledgement and activation timeouts, and the default max_in_flight and queue size. Zero limits are rejected at startup. See Configuration.
  • Built-in functions engine::observers::list, engine::observers::get, and engine::observers::dispose inspect and remove observers. Results are filtered by what the caller may invoke and never include an observer’s metadata.
  • New metrics: iii.observer.activations.total, iii.observer.dropped.total, iii.observers, iii.observer.reports.total, and iii.observer.activation.failures.total. Dropped events are counted per function and reason.
  • Observer queues share each event among the observers it matched, and all queues together hold at most 64 MiB of events. Past that budget, or past an observer’s own queue (observers.max_queue_per_observer), an activation is dropped as OVERFLOW.
  • Filters run on one evaluator per runtime thread, up to 16. A function’s completions always go to the same evaluator, so each observer’s activations keep the order their invocations completed in.
  • When an owner’s reports overflow a queue, the engine stops reading from that connection until the queue is back to half, the observer leaves active, or 250 ms pass. The owner’s SDK drops the excess from its bounded report buffer (BUFFER_FULL). A connection that hosts the observer’s target is never held.
  • Creating an observer requires that the session may invoke both the observed function and the target. The RBAC hook on_observer_registration_function_id can rewrite or deny observers, with the same contract as on_trigger_registration_function_id.
  • The engine hosts event sources as functions: engine::on-functions-changed, engine::on-workers-changed, configuration::on-updated, observability::on-log, and observability::on-span. The trigger types they replace keep firing.
  • The protocol gains feature negotiation (observe.v1, local-dispatch.v1) and the messages observe, unobserve, observeresult, observerstatechanged, functionregistered, observeradded, observerremoved, observerack, invocationsobserved, and localinvocationstats. The engine sends them only to workers that declared observe.v1. See Engine protocol.
The engine now handles UnregisterTriggerType, which every SDK sent and earlier engines ignored: the trigger type is removed when the sending worker is its provider, and its triggers move to another provider or wait for one. Remove unregisterTriggerType calls that expected the type to stay registered.

SDKs

  • Node: iii.observe, the observe and localDispatch options of registerFunction, trigger({ dispatch }), iii.currentInvocation() (carried across await), and the ObserveRejection error.
  • Browser: the same API. trigger now sends metadata, traceparent, and baggage, and handlers receive the metadata as their second argument. The current invocation is known until a handler’s first await; iii.withInvocation(inv, fn) carries it further.
  • Python: iii.observe returns a handle with ready() (blocking) and ready_async(). Hooks are declared with ObserveHooks, by keyword or in a subclass, sync or async. Observer.call has an async twin, call_async. The invocation context reaches sync handlers’ threads, and iii.filter.compile_filter evaluates filters in tests.
  • Rust: iii.observe(Source::function(..), target) and observe_with(.., ObserverOptions), RegisterFunction::observe(Observe::owner()..) with an ObserveHandler, RegisterFunction::local_dispatch, TriggerRequest::dispatch, and iii_sdk::{current_invocation, scope}. The SDK depends on the new iii-filter crate.
  • Go: client.Observe(iii.Source{..}, target, opts), RegisterFunctionOptions.Observe (iii.ObserveHooks) and LocalDispatch, TriggerRequest.Dispatch, iii.InvocationFromContext(ctx), client.IsObserved / client.Observers, and the filter subpackage.
Rust: public protocol structs gained fields and Message gained variants, so struct literals of them and exhaustive matches need updating. The connection state turns Reconnecting as soon as the link drops, a panicking handler returns invocation_failed instead of leaving its caller to time out, and an absent invocation_id is omitted instead of sent as null.

JSON messages up to 64 MiB

  • The Rust, Node, Python, and Go SDKs send JSON messages up to 64 MiB, up from 16 MiB. Messages up to 16 MiB still go out as one WebSocket text frame; larger ones are sent as one fragmented text message. Above 64 MiB, the SDK rejects the message locally with payload_too_large.
  • The engine reads up to 64 MiB per message from a worker, as one frame or as fragments, and sends every message larger than 1 MiB as a fragmented message of 1 MiB frames. A message that is too large is dropped without closing the connection: the call, or the caller of a function whose result is too large, gets payload_too_large.
  • The engine checks each message against the target worker’s receive limit and returns payload_too_large to the caller when the target cannot accept it. Workers that declare the new large-messages.v1 protocol feature (the Rust, Node, Python, and Go SDKs from 0.25.0), or that report SDK version 0.24.5 or later, receive up to 64 MiB. Browser workers and older SDKs receive up to 16 MiB.
  • The browser SDK checks its 16 MiB limit before sending and does not fragment messages.
Messages larger than 16 MiB need a 0.25.0 engine and a target worker on SDK 0.24.5 or later. Older engines forward large messages without checking the target’s limit, and an older worker may disconnect. A Rust worker on SDK 0.24.5 through 0.25.0-rc.5 that sets WorkerMetadata.version to its own version stays at 16 MiB until it moves to SDK 0.25.0. See Payload size.

Faster invocation and trigger dispatch

  • Function payloads move through handlers and WebSocket dispatch without extra copies, live triggers are indexed by type, and metric attributes are reused within each invocation.
  • In local benchmarks, dispatching to 8 matching triggers among 1,000 bindings took 8.6 µs instead of 20.5 µs (58% less time). In isolated benchmarks, direct 100 KiB calls took 28% less time and 100 KiB WebSocket round trips 11% less. These are measured examples, not a guarantee for every workload.

Deprecations

  • registerTrigger and registerTriggerType (and their spellings in every SDK, including the TriggerTypeRef helpers) are deprecated and will be removed in a future release. They keep working in 0.25 and log one warning per process per API. Use iii.observe and registerFunction with an observe descriptor instead.
  • The http, cron, queue, state, stream, pubsub, and database workers still publish trigger types in 0.25. Keep using registerTrigger for them until they ship source functions.
Rust marks the deprecated methods #[deprecated(since = "0.25.0")]. Crates built with -D warnings must allow the deprecation or migrate. See Upgrading from 0.24.x.

iii-stream and pubsub are deprecated

The iii-stream engine worker and the pubsub worker are deprecated. Behavior is unchanged in this release: existing configurations, calls and bindings keep working, and response contracts do not change. This release only adds warnings and documentation.Deprecated:
  • The iii-stream worker, its functions stream::set, stream::get, stream::delete, stream::list, stream::list_groups, stream::list_all, stream::send and stream::update, and its trigger types stream, stream:join and stream:leave.
  • The dedicated Streams WebSocket listener (default port 3112) and the iii-stream auth_function setting.
  • SDK helpers: Node and Browser createStream and IStream, Python create_stream and IStream, and the Rust legacy stream helpers.
  • The pubsub worker: function publish, trigger type subscribe, its pubsub configuration entry, and the Rust SDK SubscribeTriggerConfig and IIITrigger::Subscribe.
Not deprecated: channels, channel readers and writers, StreamChannelRef, StreamRequest and StreamResponse, the generic function and trigger registration APIs, the shared state types (UpdateOp, MergePath, StreamSetResult and related), the queue worker, and the auth_function_id of iii-worker-manager and rbac-proxy.
iii-stream and pubsub will be removed in an upcoming release, a separately announced breaking release listed in this changelog. Start moving producers and consumers to worker-owned trigger types, explicit storage with queries, channels, or durable queue topics now.
See Migrate from iii-stream and pubsub for the replacement recipes, authorization changes and the pubsub mapping.

Removed

  • The legacy console package (the iii-console binary and its frontend) is no longer built or released from this repository.
  • iii console no longer downloads or starts the legacy console. It prints a notice that points to the iii console in ADE (iii trigger compose::add worker=ade, then open http://127.0.0.1:3113) and exits with a non-zero status. The command itself will be removed in an upcoming release. See Console.
  • iii update no longer installs or updates iii-console, and iii update console and iii update --list-targets no longer accept or list it. An iii-console binary that is already installed is left in place.

Other updates

  • The engine reports log broadcast lag at most once every 10 seconds, with the number of messages skipped since the last report. Previously each lag warning was itself a log that refilled the channel, so after a burst of logs, such as 1,000 workers disconnecting, the engine kept CPU cores busy and its log grew until it was stopped. In that test, idle CPU use fell from 2 cores to none and the log from 264 MB to 755 KB.
  • Compose extracts ZIP worker packages, such as Windows worker artifacts, as well as gzip tar archives. The format is detected from the archive signature, so artifact URLs need no file extension. ZIP entries that escape the install directory or are symlinks are rejected, and Unix executable permissions are kept without setuid, setgid, or sticky bits.
Compare 0.24.6 to 0.25.0.
October 2026

Compose worker cleanup and process management

  • On Linux and macOS, Compose starts a reaper process that stops its workers and their process groups if Compose crashes, receives SIGKILL, or exits on a second shutdown signal. The reaper sends SIGTERM, waits up to 10 seconds, then sends SIGKILL. Windows behavior is unchanged.
  • Workers left running by a Compose process that is gone are stopped and started fresh on the next startup instead of being reused with their old configuration. Workers owned by a live Compose process can still be adopted.
  • Shutdown signals received within 200 ms of the first count as one request. A wrapper such as dotenv -- forwarding the terminal’s Ctrl+C no longer skips orderly shutdown. A later second signal still exits immediately, leaving the reaper to stop the remaining workers.
  • iii ps lists the current user’s local engines, Compose daemons, and workers, with ports, project ownership, uptime, and orphan or no engine status. Use iii ps --json for JSON. compose::ps provides the daemon’s view, optionally filtered by project, including processes started by its workers.
  • iii kill --port <PORT>, iii kill --compose [PATH], and iii kill --orphans stop processes by engine port, project, or orphan status. Compose daemons receive SIGTERM first so they can stop their projects in reverse dependency order. Remaining processes receive SIGTERM, then SIGKILL after 15 seconds; --force sends SIGKILL immediately.
  • Engines and the Node, Python, Rust, and Go SDKs write process markers under ~/.iii/run so iii ps can also find workers started outside Compose. Compose workers are identified by their environment, including workers built with older SDKs. Stale markers are removed when listing processes; an unwritable marker directory does not prevent a worker from starting.
See Worker Compose and the CLI reference.

Lower latency between the engine and Rust workers

  • The engine enables TCP_NODELAY on accepted worker connections and advertises x-iii-capabilities: tcp-nodelay on the worker listener, including WebSocket upgrades, telemetry, and channel endpoints.
  • The Rust SDK enables TCP_NODELAY on its worker and channel sockets only when the engine advertises support. This removes delayed-ACK stalls on small messages while preserving the existing behavior against older engines. Upgrading both the engine and Rust workers enables both sides of the change; no particular upgrade order is required.
  • In a Linux loopback benchmark with the engine and workers both updated, p95 latency fell 76–96% across the tested agentmemory routes. These are measured examples, not a guarantee for every workload; bulk channel throughput was not benchmarked.
See Engine protocol for capability negotiation and the benchmark results for the version comparisons.

SDK authentication without tokens in URLs

  • The Node, Python, Rust, and Go SDKs send a nonblank III_TOKEN as an Authorization: Bearer <token> header during the WebSocket handshake. An explicit Authorization header takes precedence, regardless of its capitalization. Workers can authenticate to an RBAC listener without adding the token to the engine URL.
  • Channels created or resolved by the SDK send the client’s handshake headers too. Channel URLs preserve existing query parameters when adding the channel path, access key, and direction, fixing connections that previously lost URL-based authentication.
  • The Node, Python, and Rust SDKs also pass handshake headers to their telemetry connection. Telemetry helpers accept explicit headers, and Python telemetry defaults to the worker’s engine address instead of a separate default address.
  • Go adds WithHeaders(http.Header) for the main connection and channels created by the client. Rust now returns a connection error instead of panicking when an invalid engine address is used with custom headers.
  • The browser SDK is unchanged because browsers cannot set WebSocket handshake headers. Manually constructed Rust and Go channel endpoints use III_TOKEN but do not inherit a client’s explicit headers.
Rust’s OtelConfig adds engine_ws_headers. Code that constructs the struct by listing every field must add it or use ..Default::default().

Trace sampling and archive limits

  • Worker spans received over OTLP are sampled per trace before local storage and collector export. Local roots now honor sampling ratios, operation rules, and rate limits instead of bypassing them because the SDK marked its spans sampled. Parent-based sampling still honors an explicitly remote parent’s sampled decision.
  • With operation rules, child spans arriving before their root can wait up to 2 seconds in a bounded buffer so the root’s operation determines the sampling decision. Sampling decisions are retained across live and completed span snapshots and reset when the policy changes.
  • SQLite archive capacity checks account for page and index costs. Existing archives gain per-trace size accounting automatically. A span that can never fit is counted in known_dropped_spans, and its trace reports completeness: partial; later smaller spans can still be stored without deleting that trace’s existing durable rows.
  • Archive writes use bounded batches, retry backoff, and rate-limited warnings so a trace that cannot currently fit, or encounters a storage error, does not stop unrelated writes. Flush reports an error when spans remain unwritten rather than reporting success.
  • Retention returns freed SQLite pages in checkpointed batches instead of creating a WAL as large as the space being reclaimed. Archive and sampling configuration defaults are unchanged.
  • Clearing metrics now updates both indexes and the total count atomically with respect to concurrent writes, queries, and retention.
The archive’s committed SQLite image is bounded by the configured cap plus a tolerance of the larger of 1% or 8 MiB. Temporary WAL and VACUUM space, readers holding snapshots, and slow storage can still cause transient disk growth or control-call timeouts; this is not a strict cap on every filesystem byte at every instant.

Lower memory use for queued HTTP bodies

  • Queued binary channel messages retain only their payload instead of the WebSocket’s entire read buffer, typically 128 KiB. Small unread HTTP POST bodies no longer keep that buffer alive until the channel expires.
  • In six-minute tests at 20 requests per second with 100-byte POST bodies, engine RSS growth fell from about 130 KiB per request to about 2 KiB. Channel lifetime, streaming behavior, and delivery to readers that connect after the writer disconnects are unchanged. Unread channels still expire normally; this fixes buffer amplification, not channel lifetime.

Python schema inference

  • Bare dict and list annotations now produce object and array schemas for function inputs and outputs. Nullable forms, including Optional[dict] and list | None, are supported. Parameterized containers keep their existing behavior.

Other updates

  • The engine’s ZIP dependency moves from the yanked 2.x release line to 9.x, with deflate decoding for Windows release archives. Invalid archive entry names return an extraction error.
Compare 0.24.5 to 0.24.6.
October 2026

Namespace routing in the SDKs

  • Calls to configuration::* without an explicit namespace now resolve in default, like engine::*, in the Node, browser, Python, Rust, and Go SDKs. Previously a worker running in another namespace failed with function_not_found unless it named default itself.
  • The Go SDK now uses the worker’s namespace (III_NAMESPACE or WithNamespace) for other calls and trigger registrations without an explicit namespace, matching the other SDKs. Previously Go sent them without a namespace, so the engine resolved them in default. An explicit namespace still takes precedence.
A Go worker running outside default that relied on calls without a namespace reaching functions in default must now name default explicitly. Calls to engine::* and configuration::* are not affected.
See Namespaces for the resolution rules.

Namespaces outside the session scope are rejected

  • When the auth function limits a session to some namespaces and the worker declares another one, the engine now sends RegistrationRejected with the code NAMESPACE_FORBIDDEN and closes the connection, as it does for WORKER_NAMESPACE_CONFLICT. Previously the SDKs did not wait for the FORBIDDEN result, so the error was lost and the worker’s functions became available in default after the connection grace period.
  • The Node, Python, Rust, and Go SDKs treat the rejection as fatal and stop reconnecting. A caller that waits for engine::workers::register still receives FORBIDDEN.
A worker that declares a namespace its session does not allow no longer runs from default. Check the namespaces your auth function returns before upgrading.

Message size limits in the SDKs

  • The Rust, Node, Python, and Go SDKs reject outgoing JSON messages larger than 16 MiB and incoming messages larger than 64 MiB. Engine limits are unchanged.
  • A rejected message returns a payload_too_large error to the matching call without closing the connection or blocking later messages. When possible, an oversized function result is returned to the caller as an error response.

Compose

  • Updates show the progress of each worker in the Compose panel, including unchanged, in-progress, completed, and failed workers. The panel and compose::status report the running version of each worker (local for path:// workers) and the iii version of a managed engine.
  • Empty values in an env_file (KEY=, KEY="", or KEY='') are ignored instead of overwriting an inherited value or an earlier nonempty value. Whitespace-only values are kept, and nonempty values set in environment: still take precedence. Compose shows a warning for each ignored entry, grouped by worker and file, without the value.
  • On Windows, environment variable names are matched case-insensitively, including across env files.
  • Compose no longer passes a stored null configuration to a worker. On a new Harness project, the second iii compose --up stopped the ide with SCHEMA_INVALID (null is not of type "object"), and harness with it. The stored entry and the worker’s own default now apply instead. A null already loaded by an earlier version in a running engine stays until the configuration is saved or the engine restarts.
See Worker Compose for details.

Installer and project setup

  • install.sh asks you to press Enter to set up your first project, which runs iii project init --learn-iii; Ctrl+C stops with iii installed. It no longer shows this prompt when iii is already installed, so upgrades skip it. When setup cannot run interactively, the installer prints the setup command instead.
  • iii project init --learn-iii no longer asks for provider API keys or other environment variables; the ADE asks for them during setup. It also stops writing config/console.yaml and the config_name of the ade container.
--start-with and --need-envs were removed from install.sh and iii project init --learn-iii. Scripts that pass them now fail with an unknown-argument error.
See Install.

Sandbox and image isolation

  • Sandboxes no longer boot on the shared image cache. Each sandbox boots on its own read-only root with the cached image as a read-only lower layer, and writes stay in memory inside the VM until it stops. Previously code in a sandbox could modify the cached image, and the change reached every later sandbox created from the same image.
  • The first sandbox::create of an image builds its read-only layer. This took 251 ms for a 124 MB image and 455 ms for a 223 MB image in measurements; later creates reuse it.
  • Builds without the embedded init, such as a plain cargo build, refuse sandbox::create with S300. Official release builds include it.
  • Images from the RHEL family, such as UBI 9, can now be extracted without root.
  • When preparing an image, iii no longer follows symlinks from the image on the host. This covers the image configuration, whiteout deletions, /etc/hosts, and init.krun. Previously pulling an image, for example with iii worker add <image> or through a worker’s runtime.base_image, could overwrite or delete host files outside its root filesystem. An image whose /etc/hosts is a symlink now gets the default file, and one whose /etc is a symlink gets none.
Sandbox images cached before this release may have been modified by a sandbox. Remove them with rm -rf ~/.iii/cache/<slug>* so they are pulled again.
See Sandboxes.

Go SDK trigger type schemas

  • RegisterTriggerType accepts an optional RegisterTriggerTypeOptions argument with configuration and callback payload schemas. engine::triggers::info returns them as configuration_schema and request_schema.
  • The SDK rejects a schema that is not valid JSON without replacing an existing registration. It does not validate configurations or payloads against the schemas; handlers still do.
  • Existing three-argument calls keep working. Interfaces or function variables that require the previous exact method signature must add the new variadic argument.

Engine fixes

  • Fixed a deadlock between stream trigger dispatch and trigger removal, including the removal that runs when a worker disconnects. Writes to every stream could stop, including streams unrelated to the trigger, while stream::get kept working.
  • Fixed a deadlock between metric writes, metric queries by name, and metric retention.
  • Fixed a race in which a worker that answered or disconnected quickly could do so before the engine recorded the pending call, leaving the caller without a response.
  • engine::functions-available fires when functions are registered or removed, instead of checking every 5 seconds. Changes within 100 ms are sent as one event with the full list. Registering an existing function ID again now also fires it.

Lower memory and CPU use

Several changes reduce memory use and allocations in the engine and the Rust SDK. The numbers below are measured examples, not a guarantee for every workload.
  • Truncated span attributes no longer keep the memory of the original value. On a long-running Harness workload, heap use with the span cache full dropped from about 1.5–1.7 GB to about 455 MiB, and peak RSS from about 1.8–2.0 GB to 950 MiB.
  • The built-in KV store makes fewer copies on reads, writes, and persistence. With 64 MiB of data, the extra memory for listing every entry dropped from 133 MiB to 67 MiB, and for file-backed writes from 324 MiB to 129 MiB.
  • On Linux with glibc, the engine returns freed memory to the OS every 60 seconds whether or not the observability worker is enabled.
  • Trace queries no longer copy span data they do not return. A query over traces with many child spans dropped from about 221 MiB to about 2.4 MiB of allocations; queries over mostly root spans were about 7% slower.
  • The engine makes fewer copies when invoking functions and handling triggers and streams. In benchmarks with small messages, engine CPU time per call fell 37–80%, while memory rose about 3 MiB.
  • The Rust SDK serializes each outgoing message once, no longer copies function definitions on each call, and applies the trace payload limit while capturing. A 64 KiB invocation allocated about half as much memory and used 19–26% less CPU.
Compare 0.24.4 to 0.24.5.
October 2026

Faster trace queries

  • engine::traces::list sorts and paginates unfiltered queries using lightweight root-span keys, then loads full spans only for the selected page, instead of decoding every stored root payload. Filtered queries use root keys and attribute and name lookups to narrow candidates before loading their payloads. Trace ordering, counts, and exact filter results are unchanged.
  • Benchmarks on copies of real trace stores reduced a 50-trace unfiltered query from 2098 ms to 35 ms and a session-scoped query from 1149 ms to 48 ms. These are measured examples, not a guarantee for every workload. Status, duration, and time filters alone can still require decoding most traces.
  • engine::traces::spans accepts include_events: false to omit events and links while keeping the other span fields. The default remains true. A benchmark with the same 120 spans reduced the response from 7.81 MB to 0.05 MB and median query time from 29 ms to 8 ms. Consumers must request the option to use the smaller response.

Compose dependency updates

  • compose::update plans selected workers and their dependency graphs together with the same planner as compose::add. This fixes conflicting declarations for shared dependencies, such as state and llm-router, when updating a Harness project without naming individual workers.
  • When a worker is both explicitly requested and reached as a dependency, its requested declaration takes precedence if both resolve to the same package and release. A different release still fails with an error naming both versions. The same rule fixes batch additions through compose::add.
  • Targeted updates preserve operator-defined tags and unpinned selectors on shared dependencies while updating the lock. Generated exact pins follow the resolved graph; operator-defined ranges are checked, and a conflicting exact pin requires an explicit versioned update. An update without a worker selection still moves selected packages to latest, while generated dependencies reached by another declared package follow that package’s graph.
  • Renamed containers resolve the package they actually run instead of adding duplicate nodes. Newly required transitive dependencies are retained. Obsolete generated dependencies are removed only when no updated graph or container outside those graphs still references them through start_after.
See Worker Compose for update rules and dependency ownership.

Linux worker compatibility

  • On Linux x86_64 hosts with glibc, the static musl build of iii can now install workers that publish only glibc builds. Compose prefers a native musl artifact when available and falls back only when the registry reports the native platform as unsupported and lists the glibc target.
  • This does not enable glibc workers on Alpine, including Alpine with libc6-compat or gcompat. Other platforms keep their existing behavior. Dependency graphs that mix a musl-only root with a glibc-only dependency are still unsupported.

Empty package configuration defaults

  • Compose treats an empty mapping in a package’s shipped defaults (default_config: {}) as absent, so the worker can seed its own runtime default on first boot. Existing locks and registry metadata do not need to be rewritten.
  • Precedence remains shipped defaults, then the active configuration, then config_override. Explicit empty mappings in the active configuration or overrides are preserved. This does not repair partially published defaults or backfill existing configuration.
If an empty shipped default has already populated the engine’s in-memory active configuration, restart the engine after upgrading so Compose prepares the configuration again.

Python SDK connection recovery

  • The Python SDK reconnects and re-registers after normal WebSocket closes, including codes 1000, 1001, and a close frame without a code (1005). Previously a normal close, such as during a Caddy reload, could leave a worker reporting connected on a closed socket.
  • A socket that fails after the handshake but before the receive loop starts is discarded so reconnection can continue. Setup failures count toward the configured retry limit.
  • An exception while handling one incoming message is logged without stopping the receive loop. Results that arrive after the caller has timed out or cancelled are discarded, and pending invocations are cleaned up after timeouts, cancellations, and send failures.
Compare 0.24.3 to 0.24.4.
September 2026

Configuration overrides reach workers through the configuration service

  • Workers receive config_override values the same way they read any other configuration: through configuration::get on the id in III_CONFIG_NAME. Compose merges package defaults, the stored entry, and config_override, and hands the result to the configuration service before the worker starts. There is no longer a separate snapshot file to read.
  • Override values live in memory only and do not change config/<id>.yaml. Saving the configuration, for example from the Console or with configuration::set, persists the whole submitted value, including fields that came from the override.
  • Removing a field from config_override does not bring back the value on disk. The worker keeps the current value, across its own restarts, until it is changed or the configuration service restarts, which discards unsaved values and reloads them from disk.
  • configuration::set accepts flush: false to update only the active value without writing to disk. Defaults seeded with configuration::ensure never replace an active override. Two containers that share one config_name must not declare different overrides.
  • III_CONFIG is retired. Declaring it in environment or an env_file fails startup with RETIRED_CONFIG_ENV.
If a worker reads the file named by III_CONFIG, switch it to configuration::get with III_CONFIG_NAME before upgrading, and remove III_CONFIG from environment and env_file declarations.
See Worker Compose and Configuration for details.

Managed engine lifetime

  • The engine that iii compose --up starts now exits with the Compose process that owns it, including when Compose is killed with SIGKILL or dies before the engine is ready, so a later start can bind the same port cleanly. The engine watches a private stdin lifeline that workers do not inherit. Workers still survive a crash of the Compose daemon (iii compose) and are re-adopted when it restarts.
  • SIGHUP, sent when a terminal window closes or an SSH session drops, now triggers the same graceful shutdown as SIGTERM. Previously it ended the daemon before teardown and left the managed engine serving on its port with no owner.

Engine channel sockets

  • When a channel’s reader leaves, the engine drops the channel and closes the writer socket immediately, even if the writer never attached. For example, the http worker drops its response reader when a function returns a buffered value. These sockets were previously held until the TTL sweeper removed them minutes later.
  • Channels created with engine::channels::create belong to the calling connection, whose disconnect releases the ends nobody has attached. In-process callers still rely on the sweeper.
  • Under bursts of HTTP traffic on macOS, the engine no longer stops accepting connections with EMFILE. Terminals on macOS start processes with a soft limit of 256 open files, and each HTTP request holds two channels. On Unix, the engine now raises its soft open-file limit toward the hard limit at startup (capped at 10240 on macOS).

Docs and agent skills

  • The website, the AGENTS.md sources, and the bundled skills/ use compose::add to add workers and iii project init to create projects, and the website includes a new AI onboarding prompt.

Migration

  • Workers: replace reads of III_CONFIG with configuration::get on III_CONFIG_NAME.
Compare 0.24.2 to 0.24.3.
September 2026

Readable configuration names

  • Without an explicit config_name, the generated configuration id is exactly <namespace>-<container-key>. For example, default plus harness uses default-harness, stored by the filesystem adapter as config/default-harness.yaml, instead of the hashed default-harness-a14f3656efb8d5ea form introduced in 0.24.1. Explicit names are unchanged.
  • Generated ids must match [a-z0-9_-]{1,64}. Compose rejects an invalid or long generated name with INVALID_CONFIG_NAME and never sanitizes, truncates, or hashes it; set an explicit config_name instead.
  • Within one project, a generated name that collides with another container’s explicit name is rejected before startup with CONFIG_NAME_COLLISION. Sharing requires both names to be explicit. Across namespaces, a-b / c and a / b-c both produce a-b-c, so choose unambiguous namespaces or distinct explicit names.
  • III_CONFIG_NAME is always present and carries the explicit id or <namespace>-<container-key>.

Automatic configuration migration

  • On the first start after upgrading, Compose renames each worker’s stored configuration to the readable id and keeps its values, including ${VAR} placeholders. The hashed id from 0.24.1 (default-harness-a14f3656efb8d5ea) becomes default-harness. In the default namespace, an entry named after the bare container key, as earlier releases stored it (state), becomes default-state. Workers with an explicit config_name are not migrated.
  • When both the old and the new entry exist, the old entry wins. The filesystem adapter keeps the original file as <old-id>.yaml.bak, and backup files are ignored when configuration loads. The one migration rewrite may reformat the YAML and drop comments.
  • Migration runs in the configuration service through the new configuration::migrate. With the bridge adapter, upgrade the remote engine together with Compose; an older remote fails startup with CONFIG_MIGRATION_FAILED instead of resetting stored values to defaults.

Runtime-only overrides

  • config_override became execution-only: Compose wrote the merged value to the private III_CONFIG snapshot and never persisted it to config/<id>.yaml or the configuration service. Removing an override restored the stored setting on the next start. 0.24.3 replaces the snapshot file with in-memory delivery through the configuration service.
  • Configuration read failures stop startup with CONFIG_FETCH_FAILED instead of silently falling back to defaults.

Guided tour

  • An image prefetch that finishes while the API key prompt of iii project init --learn-iii is on screen no longer cancels the prompt as if the operator had pressed Ctrl+C.
  • The tour’s onboarding pane opens again. Project setup adds config_name: console to the ade container in the scaffolded worker-compose.yaml, so the Console reads the layout entry the tour seeds instead of the derived default-ade. A container that already sets config_name is left alone.
See Worker Compose for configuration names and migration.Compare 0.24.1 to 0.24.2.
September 2026

Security

  • Node OpenTelemetry packages move to 2.11 / 0.222 in @iii-dev/helpers and iii-sdk. Rust moves to opentelemetry 0.32, opentelemetry_sdk 0.32.1, and tracing-opentelemetry 0.33 in iii-helpers, iii-sdk, and the engine. These updates close GHSA-8988-4f7v-96qf and GHSA-w9wp-h8wv-79jx, which allow unbounded allocation while parsing W3C baggage headers. 0.24.0 still shipped the previous versions.
  • Node SDKs update to ws 8.21 to address GHSA-96hv-2xvq-fx4p and GHSA-58qx-3vcg-4xpx.
  • The engine updates hickory-resolver and hickory-proto to 0.26 to address GHSA-3v94-mw7p-v465 and GHSA-q2qq-hmj6-3wpp, and refreshes transitive crates including quinn-proto, h2, rustls, tar, and serde_with.
  • CI uses the committed Cargo lockfile, tests iii-helpers and @iii-dev/helpers directly, and gates dependency advisories with cargo-deny and pnpm audit over the SDK packages. Dependabot adds weekly grouped updates for Cargo, npm, and GitHub Actions.

Compose environment and configuration isolation

  • Workers and lifecycle hooks inherit the machine environment visible to the Compose daemon, even without env_file or environment. Precedence is machine environment, then env files in declaration order, then the worker’s environment map, then daemon-owned reserved values. Empty environment-map values still preserve values loaded from env files.
  • III_HOST_USER_ID comes from the current project’s .iii/project.ini, not the machine environment. An explicit env_file or environment value can override it.
  • Without an explicit config_name, Compose assigns a stable configuration ID derived from the effective namespace and container key. Workers receive it through III_CONFIG_NAME, even before their first configuration value exists, so projects on a shared engine no longer collide on a worker’s default configuration ID.
  • Compose merges package defaults, the stored configuration, and config_override in that order, then publishes the result to both the configuration service and the III_CONFIG file before starting the worker. A missing entry permits defaults; a configuration-service failure stops startup instead of silently using stale defaults.
Inherited machine variables can include credentials. Only start workers you trust with the daemon’s environment, and export variables before starting the daemon; changes in another shell do not update an already-running daemon. Workers that use the configuration service must honor III_CONFIG_NAME. Set config_name explicitly when an existing configuration ID must be retained or intentionally shared.
See Worker Compose for environment precedence and configuration delivery.

Atomic configuration defaults

  • configuration::ensure creates or refreshes an entry and seeds initial_value only when no non-null value is stored. Existing values, including false, 0, and "", are preserved. Use it instead of read-then-configuration::register when workers initialize defaults. The response includes the stored entry and an action: seeded, preserved, or registered.
  • Configuration mutations are serialized within one engine’s local store, so concurrent seeds and writes do not overwrite a value based on a stale read. Filesystem watcher events reread authoritative storage before updating the cache, preventing stale events from reverting a newer local write or deleting a recreated entry.
  • With the bridge adapter, configuration::ensure forwards the original seed to the remote engine, which decides whether to preserve or seed the value. An older remote without this function returns ADAPTER_ERROR; there is no fallback that could overwrite remote settings.

Installation and release tooling

  • The installer accepts --non-interactive or any nonempty III_NON_INTERACTIVE value to suppress setup prompts even when a terminal is attached. It installs the binaries and prints the setup command instead of running onboarding. Runs without a terminal already behave this way. See Installer options.
  • The release workflow accepts a validated version_override, allowing a stable patch release after a higher prerelease version has been selected. Overrides must be newer than the latest stable tag and must not reuse an existing tag. They publish the current main code, not a historical revision or a backport.

Migration

  • Rust: if your crate depends on opentelemetry directly, move to 0.32 and tracing-opentelemetry 0.33. Prefer iii_helpers::observability::opentelemetry so the version matches the SDK.
  • Node: no SDK API change. Remove pnpm patches or overrides that forced @opentelemetry/* 2.x on older @iii-dev/helpers releases when upgrading to 0.24.1.
Compare 0.24.0 to 0.24.1.
September 2026

Compose package locks and configuration

  • Compose writes worker-compose.lock with the resolved dependency graph, exact versions, artifact URLs, SHA-256 digests, and package defaults. Starts and restarts reuse the lock rather than resolving moving tags again, and cached package contents are verified before reuse.
  • iii compose build --frozen, iii compose --up --frozen, and compose::up frozen=true require the compose file and lock to match without resolving selectors or changing the lock. Commit the lock so development, CI, and deployments use the same package content.
  • compose::add accepts container objects alongside worker names, including scripts, dependencies, environment files, configuration overrides, working directories, and timeouts. Changed declarations restart in place; unchanged workers stay running.
  • compose::update without a worker selection updates all declared package workers to the registry’s latest version and skips local workers. A targeted update resolves the selector already declared; use worker=name@latest to move an exact pin to the latest channel. Failed resolution or download leaves the previous lock and running workers unchanged.
  • Dependency resolution preserves local workers and declared pins, warns on aliases, and reuses canonical dependencies instead of adding duplicate workers.

Compose lifecycle and upgrade notes

Workers are now optional by default. Set required_default: true at the top of worker-compose.yaml, or required: true on individual containers, to fail startup and roll back when a required worker cannot start. Optional failures appear in not_required_failures even when the operation succeeds; start_after controls startup order, not whether a worker requires its dependency to succeed.
  • Restart policies support no (the default), on-failure, and always, with configurable backoff, attempt limits, and a stability window. Eligible retries keep dependents running; status reports restarting between attempts.
  • compose::add, compose::update, and compose::remove accept caller-selected operation_id values and return an acceptance response before completion. Bind compose-operation with terminal_only: true before submitting the mutation, then read compose::operation once for recovery. Acceptance is not readiness, and completion messages now report optional worker failures rather than claiming every worker is ready.
  • Runtime state and logs move to <project-dir>/.iii/compose/<namespace>/; the package cache remains shared. III_COMPOSE_STATE_DIR can redirect state to a writable location.
  • Startup shows engine, worker, and download progress, plus restart timing. Package metadata resolves concurrently, duplicate progress rows are removed, and Ctrl+C cancels startup. Linux process labels include the namespace to distinguish Compose daemons and their engines.
  • Blank environment entries no longer erase values loaded from env_file. Switching a config_override mapping’s name replaces the whole variant instead of retaining settings from the previous adapter. Worker edits also handle YAML comments correctly.
  • Compose retries transient ETXTBSY spawn failures, avoids spurious Gone errors during restarts, and rejects a container argument to compose::stop rather than stopping the daemon when a caller intended to stop one worker.
Before upgrading, stop existing Compose daemons with the old version and confirm their engines and workers have stopped. Old process records, logs, and VM data are not migrated; preserve anything you need. Add **/.iii/compose/ to .gitignore to exclude generated project state. See Worker Compose for the state layout and lifecycle settings.

Engine, CLI, and SDK reliability

  • Function-targeted triggers registered through engine::register_trigger now belong to the caller’s namespace. Provider lookup and engine::triggers::info try that namespace before falling back to default, instead of leaving valid registrations pending indefinitely.
  • engine::registered-triggers::list accepts include_pending: true, and trigger inspection reports active or pending status to make missing providers easier to diagnose.
  • A function that returns null now reaches its caller as null. The engine used to drop the field on the way through, so the Node SDK resolved undefined and a strict === null check on a state::get miss never fired. The Python SDK keeps a None return on the wire the same way.
  • iii trigger gains --engine and honors III_URL and the working directory’s compose file; --address and --port are deprecated. Compose also honors III_URL as an external engine selection and does not start a second engine when it is set.

Observability

  • The in-memory span cache is a hard-bounded FIFO: a burst of traced invocations can no longer grow it without limit or stall the engine’s WebSocket accept loop. Finalized spans evicted before the archive wrote them are reported in known_dropped_spans (completeness: partial), and the health check exposes dirty_backlog_spans.
  • Attribute values are capped at ingest (trace_storage.max_attribute_bytes, default 64 KiB); a cut iii.payload.json sets iii.payload.truncated.
  • On Linux with glibc the engine returns freed heap to the OS once a minute, so its memory follows the bounded span cache instead of staying at the last burst’s peak.
  • Read-only trace and log functions remain discoverable through engine::functions::list without include_internal; destructive clear operations remain hidden by default.

Installation and worker compatibility

  • iii project init --learn-iii creates a harness project and starts the guided tour. The installer offers the tour after installation and accepts a single keypress at its prompt.
  • The installer adds --start-with to start a harness project with selected workers, --need-envs to request their environment variables, and --skip-bin-download to run setup against an existing installation. --need-envs requires --start-with. Each selected worker receives the full project .env file, so use these options only with workers you trust. See Installer options.
  • The bundled iii-worker supports Alpine/musl with host-aware GNU or musl artifact selection during install and update. VM execution still requires KVM and an appropriate guest rootfs.
  • Node and Python worker images separate lean runtime images from builder images, and worker manifests accept the registry’s license key when runtime.base_image is set.

Telemetry and tutorials

  • III_TELEMETRY_ENABLED accepts false, 0, no, and off regardless of case or surrounding whitespace. This controls product-usage telemetry, not OpenTelemetry observability.
  • Product telemetry reports completed onboarding tour steps and deduplicates repeated step messages within an engine run.
  • The Linkly tutorial separates Agentic and Exploration paths, and installation docs use the harness template and Compose to add inference providers.
Compare 0.23.0 to 0.24.0.
September 2026

Worker Compose

  • Project workers now run through Compose and are declared in worker-compose.yaml.
  • Compose manages dependency order, lifecycle, configuration, scripts, status, and logs.
  • The legacy iii worker, worker::*, and engine-managed project worker lifecycle are removed.
  • Workers with runtime.base_image run in VMs, with source changes forwarded before restart.
Existing projects must follow Move workers from config.yaml to Compose before their first 0.23 start.

Namespaces

  • Namespaces isolate projects, workers, functions, and triggers on a shared engine.
  • Namespace support is available in the CLI, Console, and Node, browser, Python, Rust, and Go SDKs.
  • Workers without a namespace continue to use default.
  • RabbitMQ durable subscriptions now use namespace-aware queue names.
See Upgrade from 0.22.x for the queue and authorization migration checklist.

Observability

  • Traces can persist across engine restarts.
  • The Console adds observability configuration and live trace updates.
  • engine::traces::list now returns compact trace summaries. Use engine::traces::spans for full span details.

Reliability

  • Improved queue routing across namespaces.
  • Improved trace archive cleanup and summary pagination.
  • Reduced CLI telemetry traffic.
August 2026

Queue workers restore their persisted transport on restart

The queue worker now rebuilds the adapter selected in its persisted configuration before starting consumers. After the first boot removes the one-time config.yaml seed, a queue configured for a non-default transport such as RabbitMQ no longer restarts with the default transport while retaining the stored queue settings.

iii-* helper binaries resolve consistently from PATH

Every engine path that launches a sibling binary, including iii-worker and iii-console, now uses the same PATH-first resolver. A development or override binary earlier on PATH takes precedence, with the managed installation used as a fallback. iii update continues to manage the installed copy directly.

Worker installs support large dependency graphs

iii worker add and iii worker reinstall no longer reject valid dependency graphs because they are deep or contain more than 32 workers. Graphs are validated for duplicate or unreachable workers, missing dependency targets, and cycles without an arbitrary depth cap. Installs above 32 workers require explicit confirmation; pass --yes in CI or other non-interactive environments.

Sandboxes keep active workloads responsive

Sandboxes now admit a bounded number of concurrent sandbox::exec calls (four by default) instead of serializing every command. Active execs and relayed TCP, UDP, or DNS payload keep a sandbox from being reaped as idle, while a hard per-exec timeout still bounds cleanup. TCP relays also avoid delayed packet coalescing that could add about 40 ms to overlapping request-response traffic. Concurrent execs share the guest filesystem and process table, so callers that mutate shared state must coordinate access.

Observability triggers start reliably and preserve metadata

Trace trigger subscribers now wait for span storage to become available instead of silently stopping when engine startup wins the initialization race. Trace and log trigger deliveries also preserve the metadata registered with the trigger, so metadata-routed consumers receive the context they expect.

Additional reliability fixes

  • Empty traceparent, tracestate, and baggage values are treated as absent, preventing invalid W3C baggage warnings in the engine and Rust observability helpers.
  • RabbitMQ DLQ inspection and redrive operations use isolated channels, so inspecting a missing queue cannot close the channel used for publishing or consuming messages.
July 2026

Recoverable triggers survive startup races and provider restarts

RegisterTrigger no longer fails when its trigger type isn’t registered yet. Consumers that connect before providers, and provider restarts that used to drop other workers’ bindings, are now both safe.The engine optimistically stores the registration intent in pending_triggers, logs [PENDING], and acks success; when the trigger type registers — or a provider re-registers after a restart — the triggers are registered appropriately to the worker.A provider disconnect moves its live bindings back to pending ([DISABLED]) and they recover on re-register. A bind that reaches the engine while the type is registered but whose activation fails because the provider connection is closing is now stored optimistically and replayed on the next type registration too.

TriggerAction.Enqueue routes through the standalone queue worker

Enqueue actions are delivered to the registered engine::queue::enqueue provider instead of the in-process queue worker. Engine-owned receipt IDs are preserved and traceparent / baggage propagate to queued work; a missing or failing provider fails closed with enqueue_error — there is no silent in-process fallback. durable:subscriber and TriggerAction.Enqueue now require the standalone queue worker. The legacy in-engine iii-queue builtin was retired from the default config but remains available opt-in: an explicit iii-queue entry in config.yaml still boots it (enabled_by_default = false).

Per-subscriber queue retry tuning is corrected

The builtin queue adapter now applies queue_config.maxRetries and queue_config.backoffDelayMs per durable:subscriber trigger; previously every failing delivery got a fixed 3 attempts with default backoff regardless of config. Tuning is overlaid at retry-decision time, so it also covers jobs enqueued before the subscriber connected. maxRetries: N means N retries after the initial attempt (N+1 attempts total); maxRetries: 0 fails to the DLQ after one attempt, matching the RabbitMQ adapter’s counting.

engine::observability is now a required engine worker

Observability is always injected — like configuration — with defaults living in ObservabilityWorkerConfig::default(). iii worker add no longer writes an observability config: block into config.yaml, and first boot always gets a valid configuration seeded to ./config/iii-observability.yaml. ${VAR:default} placeholders (including __III_ENGINE_VERSION__) are expanded when publishing the live OTEL config.

engine::functions::list filters by multiple workers

The call gains a workers: [...] filter alongside the existing single worker: String field, listing functions owned by any of several workers in one request. When both are set they OR-combine.

Registry tags in worker manifests

iii.worker.yaml now accepts optional tags metadata: a list of strings surfaced in the generated schema for registry discovery. It does not change worker runtime behavior.

Reconnect and worker-lifecycle hardening

A batch of fixes closes reconnect and first-boot failure modes:
  • Worker reconnects no longer trip an ownership kill loop that could tear down a freshly reconnected worker.
  • First-boot worker zombies from VM transport wake starvation and SDK reconnect death are fixed; external workers are supervised and the Rust SDK reconnect path is hardened.
  • Accepted worker WebSocket sockets that never complete the upgrade are no longer held open by the idle timeout.
  • The Node SDK bounds WebSocket connect attempts and detects half-open sockets.
  • Spawned builtin daemons receive the engine’s actual WebSocket address.
  • Registry binary workers now inherit the engine’s III_ENGINE_URL / III_URL when the iii-worker-manager runs on a non-default port, so they connect instead of assuming the default port; iii-worker start/restart resolve the manager port from config.yaml when --port is omitted.

Managed worker names propagate to the SDKs via III_WORKER_NAME

iii-worker injects the config.yaml entry name as III_WORKER_NAME into every managed start path (libkrun, OCI, binary), and the Node, Python, and Rust SDKs default their reported worker name from it. A worker whose code sets no name now matches its config entry instead of self-reporting hostname:pid and timing out on iii worker add --wait. An explicit workerName / worker_name still wins; the manifest env: block cannot override the injected value.

iii worker CLI audit

A full pass over the CLI surface (every operation except add) fixed: remove now deletes the iii.lock entry so removed workers stay removed; status terminates on EngineDown instead of hanging forever and returns non-zero exit codes so it works as a scriptable readiness probe; clear (all) also wipes ~/.iii/managed; update takes the same project lock as sync and prints a friendly message when iii.lock is missing; logs drops the dead --address/--port flags and returns consistent exit codes; sandbox create/run fail fast when the engine is down instead of pulling a full image first. iii worker list / status also reflect engine truth rather than local config. A hostless iii worker add run from a non-project directory (no config file, no exported III_CONFIG_PATH) now installs through the running local engine instead.

Python SDK: connection-state listener

III.add_connection_state_listener subscribes to connection-state changes, and the SDK warns when the initial connect wait times out.

Security: SSRF via IPv4-mapped IPv6 addresses

is_private_ip in the URL validator classified IPv4 and IPv6 separately, so an IPv4-mapped IPv6 address like ::ffff:169.254.169.254 bypassed the private-range checks and could reach loopback, RFC1918 ranges, or the cloud metadata endpoint. Mapped addresses are now unwrapped to their underlying IPv4 and classified with the same rules.

Breaking: RegisterFunction::new_async_with_bad_request removed (Rust SDK)

The new_async_with_bad_request constructor is removed from the Rust SDK — source-breaking for external consumers that constructed it directly. Wire behavior is unchanged: malformed worker::* payloads still return the W105 BadRequest envelope with a worker::schema hint, a policy that now lives entirely in the worker daemon rather than the SDK.

Additional fixes

  • Node and browser SDKs default trigger generics so untyped trigger calls compile under tsc.
  • The browser SDK exports IIIReconnectionConfig and RegisterFunctionFormat, and generates invocation UUIDs in insecure (non-HTTPS) contexts.
  • The engine rejects duplicate migrated builtin workers and warns on deprecated builtin names.
  • The Docker image boots cleanly: /app/config is pre-created for the non-root user, and the config seed-strip no longer logs spurious warnings on a read-only mount.
  • iii worker add no longer rejects a valid bundle whose transitive dependencies share nodes — the dependency-graph bounds check counts unique nodes instead of distinct paths, so a wide shared graph stops overflowing the edge_traversal guard (limit 64, found 65).
  • Some workers drop the iii- prefix: http, state, queue, cron, and pubsub names.
July 2026

Register triggers from anywhere: engine::register_trigger + invocation metadata

New engine::register_trigger / engine::unregister_trigger builtins register a trigger whose fire is delivered to a function_id, with arbitrary metadata recovered at fire time. The call returns the trigger id, unregister is idempotent, and session allowed_trigger_types RBAC is honored.Per-invocation metadata is now a first-class argument through the whole invocation path — delivered to the target function as a separate argument, never injected into the payload. All four SDKs read it idiomatically: Rust two-arg closures (|data, meta: Option<Value>|) and TriggerRequest.metadata(...), Node (data, metadata?) and trigger({ ..., metadata }), Python def handler(data, metadata=None) (forwarded only if the handler declares it), Go iii.MetadataFromContext(ctx).

Triggers survive reconnects and provider reloads

Two lifecycle fixes. When a trigger-type provider re-registers (e.g. after a reload), the engine now replaces the stale registrator and replays existing bindings to it — previously new bindings landed but never fired. And engine::register_trigger bindings are durable engine-side state, removed only by explicit engine::unregister_trigger — a CLI’s short-lived connection or a proxy restart no longer reaps them. Worker-owned RegisterTrigger bindings keep connection ownership and disconnect GC.

engine::functions::list results are relevance-ranked

Search results are ordered by relevance instead of alphabetically: id-anchored matches first (a :: segment equals the needle, or the id starts with it), id substrings second, description-only matches last. The same functions are returned — only the order changes. Listing without a search term stays alphabetical.

engine::functions::info accepts batches

engine::functions::info now takes a function_ids: [...] batch (up to 32, deduplicated in request order) alongside the historical single function_id, eliminating one round-trip per function after functions::list. Batch calls return { functions: [...] } with a per-id FunctionDetail or { function_id, error: "forbidden" | "not_found" } — one bad id never fails the batch. Single-id calls keep the flat wire shape and error codes unchanged.

Live pending spans: traces show work in flight

Trace views previously only showed spans after they finished, so long-running invocations looked idle. With the new live_spans setting (default on for exporter: memory, opt-in for both), the engine mirrors spans into the in-memory store at start as pending snapshots; the final span replaces the snapshot in place, and OTLP still exports only finished spans. engine::traces::list / group_by understand pending spans (duration-so-far) and gain trace_ids batch lookup, exclude_attributes filtering, label_attribute for group headings, and merged trace_tags from iii.tag.* attributes. There is also a global feed that includes every span seen in each processing window.

Distributed traces: inbound tracestate honored, remote-parent traces visible

The engine previously seeded only traceparent on inbound HTTP and dropped the companion tracestate header; it is now honored at the HTTP boundary and exposed on spans through the traces API. Traces entering iii from a remote caller were also invisible — traces::list and traces::tree only treated spans with no parent as roots, so a trace whose server span pointed at a remote (never-stored) parent was hidden. Spans with dangling remote parents are now treated as roots. The baggage header is redacted in header logs.

RabbitMQ priority queues

The RabbitMQ queue provider supports priority queues on both function and subscriber queues. Configure max_priority (declares the queue with x-max-priority) and priority_field (an integer field in the message data that stamps each message’s priority — no enqueue API or SDK change) under queue_configs.<name>; subscribers use maxPriority in their queue_config. Main and retry queues both carry x-max-priority, so priority survives retries and DLQ redrive. Note that x-max-priority is fixed at queue creation. Subscriber consumers also gain per-consumer basic_qos prefetch for broker-side backpressure.

iii t — a shorthand for trigger

iii t my::function key=value is equivalent to iii trigger my::function key=value, with all arguments and dynamic help unchanged.

One iii.worker.yaml for the registry and the engine

Manifests published from the workers repo carry iii, deploy, and manifest top-level keys — required by the registry’s release CI. The engine’s validator treated them as unknown keys and hard-failed iii worker add, so a single manifest could not satisfy both. The validator now accepts the three publish metadata keys and ignores them at add/start time; a non-string value still gets the friendly dotted-path error.

Bundle workers: pick any rootfs, install dependencies at first boot

Bundle manifests may now set runtime.base_image to any OCI image reference (e.g. oven/bun:1), not just the engine-preset images — the rootfs is publisher-chosen, in the same trust context as the scripts.start command the engine already executes. A ref with characters that don’t belong in an OCI reference is rejected at install time instead of silently falling back to the default image at start.Bundles may also declare scripts.install — it runs once inside the sandbox VM (guarded like the rest of the boot script), which python bundles need for pip/browser bootstrap that can’t be vendored per-arch into the archive. scripts.setup stays rejected: OS provisioning belongs in the base image.

Compound scripts.start commands run correctly

A scripts.start with shell metacharacters (&&, pipes, env prefixes) is now exec’d via a quoted sh -c, so compound start commands behave the same as they do in a terminal instead of being split naively on whitespace.

Configuration: env placeholders in numeric fields, and a friendlier config directory

A lone ${VAR:default} placeholder in a numeric field — e.g. port: ${HTTP_PORT:3111} — is now env-expanded and coerced to the right scalar type, so schema validation passes instead of rejecting it as a string. The default config store moved from ./data/configuration to ./iii-config (one-time auto-migration on first boot; explicit directory: overrides are untouched), and direct edits to ./iii-config/*.yaml hot-reload through the same rebind path as configuration::set. The HTTP worker also gracefully shuts down the old listener when its host or port rebinds.

Docker projects start cleanly on Linux

iii project init --docker && docker compose up could crash-loop on Linux: the distroless engine image runs as non-root UID 65532 with no writable data directory. The image now pre-creates /app/data and /data owned by that UID, and the generated compose template mounts an iii_data named volume at /app/data, so configuration data persists across restarts. Generated compose files also load .env via env_file, so ${VAR} placeholders in worker configs resolve from the project’s .env. The stale port 9464 exposure is dropped.

exporter: both no longer strips the service name from OTLP spans

With exporter: both, the tee exporter didn’t forward set_resource to the wrapped OTLP exporter, so every engine span shipped with an empty resource — Tempo showed root spans as <root span not yet received> and span-metrics lost their service label. Engine spans now carry the configured service_name and version in OTLP payloads. Pure exporter: otlp was never affected.

Node SDK: ChannelWriter reconnects after a dropped WebSocket

After a connection drop, sends went to a dead socket — throwing or silently dropping data — because the writer’s ready flag was never reset on close/error. Sends now check the live socket state, requeue on failure, and the writer transparently reconnects and flushes the queue when the new socket opens.

Worker configuration moves to ./config and edits apply in real time

Every worker’s runtime settings live in the configuration worker — one schema-validated YAML file per worker — and 0.21 makes that layer visible, relocatable, and truly live. The new Using iii / Configuration page documents the full lifecycle: a worker’s config: block in config.yaml is a first-boot seed; after that, its settings are managed in real time from the file on disk, the console, or configuration::set.The default store moves from ./data/configuration/ to ./config/. When running on the default location, entries still in ./data/configuration are migrated across once on boot (logged at INFO); a file that already exists at the new location is skipped with a WARNING and the legacy copy is left in place to reconcile. An explicit directory: override disables the migration entirely. The folder is set on the configuration worker’s own block:
config.yaml
Editing a persisted file on disk now hot-applies. Direct edits to ./config/<id>.yaml are picked up by the fs adapter’s watcher (the watch path is canonicalized, fixing macOS FSEvents on relative paths) and fan out through the same validate/apply path as configuration::set — a hand edit rebinds a port the same way an SDK call does. Edits are validated against the registered schema after ${VAR} expansion: an invalid edit is rejected with a warning and the previous good value stays in effect. The worker’s own writes are deduped so a save can’t trigger a reload loop.${VAR} placeholders work in numeric fields. A field that consists of a single placeholder — e.g. port: ${HTTP_PORT:3111} — is env-expanded and YAML-coerced to the schema’s scalar type (int, bool, float), so it validates as the integer 3111 instead of being rejected as a string. Validation always runs against the expanded value; the template is stored verbatim and re-expanded on every read. A placeholder with no env value and no default keeps the entry on its last valid value instead of failing the load.Host/port rebinds free the old port for real. When a configuration change rebinds the iii-http listener, the old server is now shut down gracefully — closing its open connections, including idle keep-alive connections that would otherwise keep serving the old address — with a hard-abort backstop after 10 seconds so a stuck connection can’t pin the old port. The engine REST API server rebinds the same way.
June 2026

SDK: a major, breaking reorganization of the public surface

The Node, Python, and Rust SDKs are reorganized for a consistent, well-factored public surface. Names are aligned across the three languages, the shared types move into a new package, and the rest of the root surface is grouped into named submodules. This release is breaking: many symbols move out of the SDK root.
Upgrading an existing app? Follow the step-by-step Upgrading from 0.19.x to 0.20.x guide.

Shared types extracted into @iii-dev/helpers

The HTTP, queue, stream, and worker-connection-manager types move out of the root SDK into a new separately published package, @iii-dev/helpers (npm), iii-helpers (crate and PyPI, import iii_helpers), organized into four submodules. The root exports are removed; import these types from the helpers package.IStream, the streaming StreamRequest and StreamResponse, MiddlewareFunctionInput, and TriggerActionEnqueue stay in the root SDK. See the helpers entry for the full symbol list and migration.

Cross-language parity: renames and removals

  • Main client handle aligned to IIIClient (Node was ISdk, Rust was III).
  • Telemetry config aligned to TelemetryOptions (Rust was WorkerTelemetryMeta).
  • Buffered HTTP request and response renamed ApiRequest and ApiResponse to HttpRequest and HttpResponse; streaming keeps StreamRequest and StreamResponse. Rust StreamCallRequest and StreamJoinLeaveCallRequest become StreamChangeEvent and StreamJoinLeaveEvent.
  • Stream helper type names aligned across the three SDKs: the change-event detail is StreamChangeEventDetail everywhere (Rust was StreamEventDetail, Node was an inline object), the result types are StreamSetResult / StreamUpdateResult / StreamDeleteResult (Rust had bare SetResult / UpdateResult, Node and Rust had a bare DeleteResult), and MergePath is now a named export in all three.
  • Removed: UpdateBuilder, the Rust Value re-export, IIIForbiddenError and IIITimeoutError (Python), the TriggerActionType alias, the Rust FieldPath path helper, and the root Logger and OpenTelemetry re-exports (use @iii-dev/observability).
See the parity entry for the full table.

Submodule grouping

The remaining root surface is grouped into submodules (iii-sdk/<name> / iii.<name> / iii_sdk::<name>):
  • errors (InvocationError, Error), channel, trigger, runtime: the first slice. The III prefix is dropped from the invocation error types. These keep their old root paths working as deprecated aliases. See the first slice.
  • engine (EngineFunctions, EngineTriggers, RemoteFunctionHandler), protocol (the message and register-input types), internal (InternalHttpRequest), and Python utils (the format helpers): these move out of the root with the root export removed. IIIConnectionState (runtime) and TriggerActionVoid (Python trigger) likewise leave the root. See the submodule entry.

Compatibility

This release is not backward compatible. The first errors/channel/trigger/runtime slice keeps deprecated root aliases, but everything extracted into @iii-dev/helpers, the renamed types, and the engine/protocol/internal/utils groups are removed from the root SDK. Update imports to the submodule and helpers paths.

Observability moved into @iii-dev/helpers

The Logger, initOtel, withSpan, executeTracedRequest, and the rest of the public observability API move from the standalone iii-observability packages into a new observability submodule of the helpers package. The OpenTelemetry dependencies are now bundled in helpers (net-neutral for SDK users). The standalone @iii-dev/observability / iii-observability packages become deprecated shims that re-export from helpers; they continue to work but emit a deprecation signal. See the observability entry for the full path table and migration.
June 2026

Saving a file in a local overlay worker restarts cleanly the first time

Editing and saving a file in a local-path worker on the overlay copy-in path could fail on the first save with iii: ERROR /mnt/host-src … is not a virtiofs mountpoint, then work on the next save. The host source watcher tried an in-VM fast restart first — but that re-runs the boot script, and in copy-in mode the script detaches /mnt/host-src once it has copied your project into the VM. The re-run hit the now-absent share and aborted; the watcher then fell back to a full VM restart, which is why the second save worked.The fast in-VM restart is now skipped for overlay copy-in workers, so they take a full VM restart — the only path that re-copies your edited source. (Even when it didn’t error, a fast restart would have run stale source, since the project is copied at boot rather than live-mounted.) Legacy live-mount workers keep the fast restart; bundle workers don’t auto-restart on source edits at all. Net: one save, one clean restart, running your latest changes.

config.yaml worker blocks are stripped once their value is persisted

Every worker’s runtime settings already live in the configuration worker — the config.yaml block was seed-only, read once on first boot and ignored afterwards. But the block still sat in config.yaml, looking authoritative while being dead. Now, once a worker’s value is persisted in the configuration store, its config: block is removed from config.yaml on the next boot: the - name: entry is kept and the block is replaced with a one-line breadcrumb pointing at where the value now lives (./data/configuration/<id>.yaml with the default fs adapter) and how to change it (iii config set <id> or configuration::set). This covers built-in workers (stripped on the first boot after they seed) and external workers like shell that register over the bus (stripped on the first boot after their value is in the store).The rewrite is line-based and byte-preserving on purpose — it deletes only the contiguous block and inserts one comment, so comments, key order, and ${VAR:default} placeholders in other blocks are left exactly as written (a serde_yaml round-trip would have mangled all three). The file is written atomically, and the strip is best-effort: a read/write failure is logged and never fails boot, and a second boot with the block already gone is a no-op. The strip runs before the file watcher starts, so it never trips a config reload.Relatedly, the persisted store files are now value-only: the configuration worker writes just the setting value to <id>.yaml and no longer dumps the JSON schema alongside it, so the on-disk files are smaller and hand-editable.
June 2026

Overlay workers boot on every architecture

The shared read-only overlay base introduced in 0.19.5 was built as squashfs, but the libkrunfw guest kernel on x86_64 ships without CONFIG_SQUASHFS. Mounting the lower returned ENODEV, the overlay never assembled, the VM never reached ready, and the worker timed out. It only surfaced on x86_64 — aarch64 (Apple Silicon) dev kernels carry squashfs, so local boots passed while Linux/x86_64 hosts and CI runners failed.The overlay lower is now built as erofs — the one read-only filesystem the libkrunfw kernel mounts on every architecture (erofs + lz4 are present on both x86_64 and aarch64). The image is uncompressed, so the guest mounts it with no decompressor and pays no read-time decompression cost. The committed libkrunfw firmware is refreshed (kernel 6.12.68, ABI soname unchanged) to a build that carries erofs on every arch. Building the base is a pure-Rust, std-only erofs writer with no external dependency, and it streams through an on-disk spool to stay within bounded memory.Trade-off: an uncompressed erofs base cache is larger than the old squashfs+gzip (~3.3×, observed 763 MB vs 230 MB for the node base). A worker’s stale <name>.sqfs base from a 0.19.5 prerelease is garbage-collected the next time the base is extracted.The overlay opt-out is unchanged — III_ROOTFS_MODE=legacy (env) or rootfs.mode: legacy in config.yaml falls back to the per-worker-clone boot.
June 2026

Workers share one read-only rootfs instead of cloning it per worker

A managed worker used to get a full private copy of its base-image rootfs under ~/.iii/managed/<worker>/. With several workers on a box that duplication dominated disk use. Workers now boot from a shared read-only base — built once per base image, host-side — layered with a per-worker writable ext4 upper via overlayfs; iii-init assembles the overlay and pivot_roots into it. The per-worker managed directory drops from a full clone to a small trampoline plus a sparse upper file — in a node-worker test, 731 MiB → ~9 MiB on disk — and the base image is stored once and reused across every worker built on it. The model is image-independent: the upper ships as a prebuilt golden ext4, so even a minimal or distroless base with no filesystem tools boots, and nothing inside the image is used to assemble the overlay.Overlay is on by default, but activates only when iii-worker is built with an embedded, version-matched iii-init — so default development builds keep the exact legacy clone behavior. Opt out explicitly with III_ROOTFS_MODE=legacy (env) or a top-level rootfs.mode: legacy in config.yaml; both fall back to the per-worker-clone boot with no overlay disks attached. The env var wins over config.yaml when both are set. On a worker’s first overlay boot, the orphaned dep cache from the old layout is reclaimed.

Local workers no longer leave empty dependency folders in your project

A local-path worker mounted your project directory writable into the VM, so the dep and build directories created during install (node_modules, .venv, dist, …) leaked back onto the host as empty folders. Under overlay, a local worker now mounts the host project read-only and copies the source into a VM-local workspace on the upper (dep and build dirs excluded at any depth), so the host project is only ever read — those phantom folders no longer appear. Bundle and legacy workers keep the live mount.

Overlay local workers boot reliably and pack large base images

Two reliability fixes for the new overlay boot path:
  • The host source share mounts reliably. In copy-in mode the host project is shared into the VM as a virtio-fs device that iii-init mounts right after the root pivot. virtio-fs device registration is asynchronous, so a mount issued too early could transiently fail — and because that share is boot-critical (the worker aborts when it isn’t mounted), it surfaced as an intermittent boot failure (/mnt/host-src … is not a virtiofs mountpoint). iii-init now retries each share mount for ~2s to ride out the device-probe race; a genuinely failed mount logs the cause instead of being silently swallowed.
  • Large base images pack within bounded memory and file descriptors. Building the shared read-only base streams each file’s bytes through a single on-disk spool instead of holding the whole image in memory or keeping one open descriptor per file, so even a large base (the node image carries ~15k files) packs cleanly.

iii-pubsub runtime settings live in the configuration worker

The pub/sub worker now registers its config schema under the iii-pubsub configuration entry, seeds it from the config.yaml block on first boot, and hot-applies changes via configuration::set — no restart.The pub/sub adapter is a full hot-swap tier (unlike iii-state’s restart-tier storage adapter): a runtime edit rebuilds the backend — local (in-process broadcast) or redis (cross-instance delivery) — re-subscribes the live subscriptions onto the new backend before swapping it in so there is no delivery gap, then tears down the previous backend, aborting its per-topic tasks rather than leaking them. A value that fails to build the backend is gated and keeps the previous one running.The adapter field advertises a concrete per-adapter schema — a discriminated union keyed on name over the built-in local and redis adapters. Each branch is closed: redis carries a typed redis_url, and local (which takes no config) is a closed empty object, so configuration::set rejects an unknown adapter name and a junk config key on either branch, while a schema-driven UI renders per-adapter fields instead of a free-form object. Deserialization stays lenient, so a hand-edited persisted file is still tolerated at boot.As with the other workers, the config.yaml block is seed-only once a value is persisted; change settings via configuration::set or by editing the persisted file (./data/configuration/iii-pubsub.yaml with the default fs adapter). ${VAR:default} placeholders expand on read.

iii-stream runtime settings live in the configuration worker

The WebSocket stream worker now registers its config schema under the iii-stream configuration entry, seeds it from the config.yaml block on first boot, and hot-applies changes via configuration::set — no restart. The schema carries per-field descriptions, rejects unknown keys at set time, and expands ${VAR:default} placeholders on read. As with the other workers, the config.yaml block is seed-only once a value is persisted — change settings via configuration::set or by editing the persisted file (./data/configuration/iii-stream.yaml with the default fs adapter), and a runtime edit survives engine restarts.Each field applies on its own tier. auth_function applies to new connections immediately — no rebind. A host/port change rebinds the listener: the new address is bound and the server respawned before the old listener is torn down, a failed bind keeps the previous server, and live connections on the old address are dropped (clients reconnect). An adapter change hot-swaps the pub/sub backend: the new backend is built, swapped in, and its event pump restarted; the swap is gated (a value that fails to build keeps the previous backend), and existing connections stay bound to the previous backend until they close, so prefer a quiet moment to repoint the adapter in a multi-instance deployment.The adapter field advertises a concrete per-adapter schema — a discriminated union keyed on name over the built-in kv, redis, and bridge adapters, each with its own typed config (kv: store_method / file_path / save_interval_ms / channel_size; redis: redis_url; bridge: bridge_url). configuration::set validates adapter settings against it and rejects an unknown adapter name or stray config key, and a schema-driven UI renders per-adapter fields instead of a free-form object. Deserialization stays lenient, so a hand-edited persisted file is still tolerated at boot.

iii-cron runtime settings live in the configuration worker

The cron worker now registers its config schema under the iii-cron configuration entry, seeds it from the config.yaml block on first boot, and hot-applies changes via configuration::set — no restart. The config.yaml block is seed-only once a value is persisted; change settings via configuration::set or by editing the persisted file (./data/configuration/iii-cron.yaml with the default fs adapter), and a runtime edit survives engine restarts. ${VAR:default} placeholders expand on read, and values are validated against the schema (which carries a per-field description and rejects unknown keys) at set time.Cron’s only setting is the distributed-lock adapter, and a change is a full hot-swap: the new lock backend is built first (gated — a value that fails to build keeps the previous backend, config, and jobs), the old backend is shut down, then every live cron job is re-registered onto the new one (best-effort per job), and the (config, scheduler) pair is swapped in atomically. The brief swap window is a scheduling gap on this instance — a job whose fire time lands in it is skipped, not double-run — while across a multi-instance fleet the old and new lock backends cannot coordinate mid-migration, so prefer a quiet moment to repoint the adapter.The adapter field advertises a concrete per-adapter schema — a discriminated union keyed on name over the built-in kv and redis lock backends, each with its own typed config (kv: store_method / file_path / save_interval_ms / lock_ttl_ms / lock_index; redis: redis_url only, since the lock TTL and key prefix are fixed by the adapter). configuration::set validates adapter settings against it and rejects an unknown adapter name or a stray config key (e.g. a redis lock_ttl_ms), and a schema-driven UI renders per-adapter fields instead of a free-form object. Deserialization stays lenient, so a hand-edited persisted file is still tolerated at boot.

iii-observability is fully configurable through the configuration worker

The engine’s observability worker registers its complete configuration surface — trace/metric/log exporters, sampling (ratio, per-operation rules, rate limits), in-memory store limits and retention, alert rules, span-collapse rules, and the engine log level/format — under the iii-observability configuration entry. The config.yaml block seeds the entry on first boot and is ignored afterwards; runtime edits survive restarts.Changes hot-apply per field tier: ingest gates, logs_console_output, and logs_sampling_ratio are read live; store limits retune on the next insert/sweep; the sampler, alert rules (with cooldown/firing continuity for surviving rules), span-collapse cache, and engine log level rebuild and swap in place; the OTLP logs exporter and log-retention tasks restart with new settings. Restart-tier fields (trace exporter wiring, resource identity, log format) are warned about and apply at the next engine start — the persisted entry is read at boot, before logging init, so they genuinely take effect.The schema rejects invalid values at configuration::set time (unknown fields, sampling ratios outside 0..=1, zero counts, log batch/flush sizes outside the exporter’s accepted range), and out-of-range values that predate a schema tightening are clamped on read.engine::alerts::list now also returns the configured rules alongside the live alert states, so a caller sees the active thresholds and their firing status in one call.This work also fixed three latent dead-config bugs: metrics_max_count/metrics_retention_seconds and logs_max_count were silently shadowed by defaults claimed before the worker booted, and advanced sampling rules never reached the engine tracer because the sampler was built before the global config existed. All three now apply — and the first two are live-tunable.

iii-queue runtime settings live in the configuration worker

The queue worker now registers its config schema under the iii-queue configuration entry, seeds it from the config.yaml block on first boot, and hot-applies changes via configuration::set — no restart. Per-queue settings live in queue_configs, keyed by queue name (with the always-present built-in default queue): max_retries, concurrency, type (standard or fifo), message_group_field, backoff_ms, and poll_interval_ms. A runtime edit diffs queue_configs and restarts only the consumers that changed — added queues start a consumer, removed queues stop theirs, untouched queues keep running. The schema carries per-field descriptions and rejects invalid values at set time, including concurrency: 0 (which would otherwise panic the consumer’s bounded channel) and a fifo queue with no message_group_field; the same guards re-run on the config.yaml seed, which bypasses the schema.The adapter is a full hot-swap tier (like iii-pubsub, unlike iii-state’s restart-tier storage adapter): a runtime edit re-instantiates the transport and restarts every consumer on it, with no engine restart. The new transport is built first and the swap is gated — a value that fails to build keeps the previous transport, config, and consumers running. Because the new transport starts empty, messages still queued on the old one do not migrate, and in-flight deliveries finish acking against the old transport before it is dropped — so prefer a quiet moment to repoint the adapter.The adapter field advertises a concrete per-adapter schema — a discriminated union keyed on name over the built-in builtin (in-process, the default), redis, and bridge adapters, each with its own typed config (builtin: store_method (in_memory / file_based) / file_path / save_interval_ms; redis: redis_url; bridge: bridge_url). The rabbitmq adapter and its config (amqp_url / max_attempts / prefetch_count / queue_mode) join the union only when the rabbitmq feature is compiled in, so configuration::set never accepts an adapter name the engine cannot build. The set is closed: configuration::set rejects an unknown adapter name or a stray config key, and a schema-driven UI renders per-adapter fields instead of a free-form object. Deserialization stays lenient, so a hand-edited persisted file is still tolerated at boot.As with the other workers, the config.yaml block is seed-only once a value is persisted; change settings via configuration::set or by editing the persisted file (./data/configuration/iii-queue.yaml with the default fs adapter). ${VAR:default} placeholders expand on read.
June 2026

iii.worker.yaml is validated before anything is installed

Local worker add (CLI and worker::add trigger) now validates the manifest before any artifact is written. A typed schema is the single source of truth, and it is strict where it matters:
  • Unknown keys are rejected — collected and sorted, so a manifest with several typos surfaces them all in one run:
  • Deprecated keys warn but still work (runtime.kind / package_manager / entry / language, top-level config / language / entry) — honored for now, scheduled for removal in a future version, with migration hints per key. Silence with III_NO_DEPRECATION_WARN=1.
  • Shape errors read in plain English — runtime: node reports “runtime must be a mapping”, not serde’s “expected struct RuntimeSection”. A manifest missing name says so precisely instead of the false “No project manifest detected”.
  • The same strict validation re-runs at start, with a 64 KiB size cap that defuses hostile (billion-laughs) or accidental multi-GB manifests before they are slurped into host memory.
Breaking: a local manifest with unknown keys that previously installed silently now fails worker::add. Fix the typo or remove the key — worker::validate (below) tells you exactly which.

Author → check → add: worker::validate and the manifest contract

Two new ops close the authoring loop for LLMs and automation:
  • worker::validate dry-runs a manifest — inline string or host path — and returns { valid, name, errors, unknown_keys, deprecated_keys, warnings } without touching anything. valid: true means worker::add would accept it.
  • worker::schema { function_id: "iii.worker.yaml" } serves the manifest’s JSON Schema (closed-world: every field described, unknown keys rejected) plus a ready-to-write hello-world bundle using the correct iii-sdk package — self-tested against our own validator so the example can never drift from the rules.

worker::status: one worker, full picture

worker::status { name } reports a single worker’s config entry, sandbox state, process liveness, and recent logs in one call, with actionable hints — a local worker without logs yet reports that it is still provisioning rather than implying failure. Like every worker::* op it carries schemars-generated request/response schemas via engine::functions::info.

Local-path workers: stop re-adding after code edits

Local workers run their project directory live and get a host-side source watcher: editing a source file auto-restarts the worker, and editing a dependency manifest (package.json, Cargo.toml, …) forces a full restart. Re-running worker::add after a code change is pure waste — it re-registers triggers and re-runs install scripts. The worker::add / worker::update descriptions now say this to LLM callers directly: re-add with force: true only when iii.worker.yaml itself changes, and worker::update never touches local-path workers (it re-resolves registry workers pinned in iii.lock).Also fixed: a forced local re-add returned the raw path (/tmp/hello-world) as the worker name instead of the manifest name (hello-world) — no other worker::* op accepts the path form, so the response was a dead end.

worker add --force reruns install when dependencies change

--force is the documented way to rebuild a local worker after its iii.worker.yaml or lock file changes. It stops the worker and clears artifacts, but a leftover binary or OCI image under ~/.iii could trip an if freed == 0 guard that left the managed directory in place — and with it the .iii-prepared marker and the /var/iii/deps caches. The next boot saw the marker, skipped setup/install, and reused stale dependencies, so a freshly added package surfaced as a ModuleNotFoundError at runtime.The managed directory is now always wiped on --force (it is a distinct path from the image cache, so there was never a double-count to guard against), and the .iii-prepared marker is removed explicitly as a backstop even when the directory wipe partially fails. A changed lock file reinstalls.

W120 tells you when the lock holder is the daemon itself

When the worker lock is held by the iii-worker-ops daemon — the process serving the worker::* API — the W120 LockBusy error now says so and tells the caller not to kill that pid. From a real harness session: an LLM saw “lock held by pid N”, killed N, and took down the entire worker management API it was using.

worker::logs output is terminal-sanitized

Logs fetched over the bus are stripped of ANSI/OSC escapes and spinner residue — raw escape bytes are token noise for LLM consumers and can rewrite the reader’s terminal. Pass raw: true to opt out.

iii-state runtime settings live in the configuration worker

The state worker now registers its config schema under the iii-state configuration entry, seeds it from the config.yaml block on first boot, and hot-applies changes via configuration::set — no restart. Two new runtime knobs apply live: triggers_enabled globally pauses/resumes state change-trigger fan-out, and max_value_bytes rejects oversized state::set writes with VALUE_TOO_LARGE. save_interval_ms retunes the file-backed kv save cadence by respawning the save loop. The schema carries per-field descriptions and rejects invalid values (e.g. max_value_bytes: 0, save_interval_ms: 10) at set time.The storage adapter is restart-tier: a change is logged and takes effect at the next engine start, where a boot-read of the persisted iii-state entry drives adapter construction — so a runtime-edited adapter survives restarts. As with the other workers, the config.yaml block is seed-only once a value is persisted; change settings via configuration::set or by editing the persisted file (./data/configuration/iii-state.yaml with the default fs adapter). ${VAR:default} placeholders expand on read.The adapter field advertises a concrete per-adapter schema — a discriminated union keyed on name over the built-in kv, redis, and bridge adapters, each with its own typed config (kv: store_method / file_path / save_interval_ms; redis: redis_url; bridge: bridge_url). configuration::set validates adapter settings against it and rejects an unknown adapter name or stray config key, and a schema-driven UI renders per-adapter fields instead of a free-form object. Deserialization stays lenient, so a hand-edited persisted file is still tolerated at boot.

OTLP observability exports work with TLS collectors

OTLP trace and metric exporters now honor OpenTelemetry protocol environment variables and automatically enable TLS for HTTPS gRPC collector endpoints. HTTP/protobuf endpoints are normalized to the correct /v1/traces or /v1/metrics signal path even when the configured endpoint already includes another OTLP signal path.Log export also applies headers from OTEL_EXPORTER_OTLP_LOGS_HEADERS or the global OTEL_EXPORTER_OTLP_HEADERS, including percent-decoded header values, so authenticated collectors receive the configured credentials.

Hardening

  • The VM UDP relay now writes a real pseudo-header checksum on response packets (strict guest stacks drop zero-checksum UDP), bounds payloads to the 16-bit IP length limit instead of silently truncating, and no longer injects empty frames into the guest’s rx ring.
  • Shell frame readers (proto, client, relay) zero-initialize their buffers instead of using uninitialized memory before the socket read.

iii-http runtime settings live in the configuration worker

The HTTP worker now registers its config schema under the iii-http configuration entry, seeds it from the config.yaml block on first boot, and hot-applies changes via configuration::set — CORS, timeout, concurrency limit, and global middleware swap without dropping the listener; a host/port change binds the new address before tearing down the old one, and a failed bind keeps the previous server running. ${VAR:default} placeholders expand in string fields. The schema carries per-field descriptions and rejects invalid values (including concurrency_request_limit: 0) at set time.The config.yaml block is seed-only: once a value is persisted, later edits to that block are ignored — change settings via configuration::set or by editing the persisted file (./data/configuration/iii-http.yaml with the default fs adapter).Breaking — HTTP error envelope: errors generated by the iii-http server itself (handler invocation failure, middleware failure/timeout, unmet route condition, route-miss 404s, and response-stream build failures) now return {"error": {"code", "message", "error_id"?}} instead of flat strings or plain-text bodies. code is machine-readable (e.g. MIDDLEWARE_TIMEOUT, CONDITION_NOT_MET, INTERNAL_ERROR, NOT_FOUND); error_id correlates 5xx responses with server logs. Bodies returned by your own handlers and middleware pass through unchanged. Clients parsing the old flat string must read error.message instead.
June 2026

Nothing the engine started survives it — zombie-worker leak fixed

An abnormal engine death (kill -9, OOM, crash, dev hard-restart) used to leave the engine’s entire spawn tree running forever: orphaned worker-manager-daemon / sandbox-daemon processes reconnect-looped indefinitely (one incident accumulated 19), an orphaned sandbox-daemon pinned live multi-GB libkrun VMs, and managed-worker VMs, __watch-source sidecars, and binary workers all survived a killall -9 iii.Engine death is now detected three ways — a lifeline pipe the kernel closes on any death including SIGKILL, an III_ENGINE_PID handshake that flows down the whole spawn tree, and a hardened reparent fallback — and triggers a cascade that tears down daemons, VMs, watchers, and workers. The worker-ops daemon runs a session reaper that stops every config.yaml worker host-side without needing the engine. Self-exits leave a one-line breadcrumb in ~/.iii/logs/<daemon>.log. Residual: binary workers leak only if the engine and the worker-ops daemon are SIGKILLed in the same instant.

Daemon no longer panics when the engine dies with broken stdio

When the engine died abnormally, its stdout/stderr pipes became broken, and the SDK connection thread’s reconnect logging hit the tracing layer’s eprintln! fallback — which panicked (failed printing to stderr: Broken pipe) and killed the daemon before its engine-gone reaper could run. The tracing writer now swallows write errors (ResilientStdout) so logging can never panic the process; the durable exit-log redirect stays for forensics.

Live trace feed is tree-correct and lower-latency

Spans whose real parent was an engine-internal wrapper rendered as detached “phantom roots” in the live detail view. The detail stream now rebuilds each touched trace through the same prune+collapse pipeline as the REST tree, emitting corrected parent_span_id and the full ancestor chain (upsert-by-span_id, so frames stay self-contained). The Node SDK’s BatchSpanProcessor flush delay drops from OpenTelemetry’s default 5000ms to 100ms (configurable via spansFlushIntervalMs), cutting the dominant source of console lag; console trace polling drops from 3s to 1s.

Go SDK: connection-scoped replies no longer leak across reconnects

The Go SDK drained connection-scoped replies (pong, InvocationResult, TriggerRegistrationResult) from a single shared outbound channel. If a reply was buffered when the socket dropped, the next connection’s writeLoop could send that stale reply on the wrong socket. Replies now route through a per-connection channel created on connect and detached on teardown; a reply enqueued with no live connection is dropped.
June 2026

Conflicting HTTP routes are rejected instead of crashing the worker

Registering two HTTP routes with identical structure but different path-parameter names — e.g. GET users/:id and GET users/:userId — used to panic axum’s matcher and take down the entire iii-http worker thread. register_router now detects the structural conflict up front and rejects the second registration with a descriptive error:
The first route keeps serving; only the conflicting registration fails, and the worker stays up. Route conflict tests were added across the Go, Node, Python, and Rust SDKs.

HTTP trigger unregister is owner-aware

When two workers registered the same method + path — during a rolling deploy or a reconnect — a departing worker’s route cleanup could delete the route the new worker had just taken over, dropping the endpoint to a 404. unregister now checks ownership (trigger_id + worker_id) and skips the removal when the route already belongs to a different owner, so the live worker’s route keeps resolving. Removal by the actual owner is unchanged.

Install script retries transient download failures

install.sh now wraps every GitHub API call and binary download (iii, iii-init, iii-worker) in a retry (--retry 5, --retry-delay 2, --connect-timeout 10). Transient 5xx responses and connection timeouts are retried instead of failing the install on the first hiccup. Only widely-supported curl flags are used, so older curl builds keep working.

worker::* management API is self-describing — and kind: "local" now works over the trigger

Every worker::* op (add/remove/update/start/stop/list/clear/schema) now publishes its request JSON Schema, a description, and default_timeout_ms / idempotent metadata through engine::functions::info and worker::schema, so an LLM or automation caller can discover the full contract without out-of-band docs. Workers can also report a one-line description (Node, Go, Rust, and Python SDKs) that surfaces in engine::workers::list / engine::workers::info.Breaking — error codes on the wire:
  • Malformed worker::* payloads now return W105 (BadRequest) instead of W101 (InvalidSource). The envelope’s details.hint names the worker::schema call that returns the request schema. W101 and W102 are now reserved (documented but never emitted) — consumers matching W101 for malformed payloads should match W105.
  • worker::* op failures now surface the W-code as the transport ErrorBody.code (previously the generic "invocation_failed", with the W-code only inside the message envelope). Consumers that matched code == "invocation_failed" to detect worker-op failures should match the W-code instead.
Breaking — worker::add { kind: "local" } over the trigger: the identical request that previously returned W102 (rejection) now succeeds. The path resolves on the engine/daemon host and the install runs the manifest’s setup/install/start scripts there. Because the engine does not authenticate worker identity, treat a daemon reachable by untrusted workers as a host-level code-execution surface — prefer registry names or OCI references for distributed workers, and lock down the daemon when exposing it.
June 2026

engine::triggers::info now exposes response_schema

Trigger types can declare the schema a bound handler must return when the trigger fires. engine::triggers::info surfaces it as a new optional response_schema field alongside the existing configuration_schema (how to configure the trigger) and request_schema (what the handler receives) — the full trigger contract is now discoverable from a single call:
The http trigger type is the first to declare a return contract: its response_schema is the response envelope the iii-http worker reads from a handler’s return value — status_code / headers / body, every field optional. Previously, “what should my HTTP handler return” wasn’t discoverable from the trigger itself: you had to inspect an already-bound handler via engine::functions::info, or guess field names (status vs status_code — it’s status_code).Trigger types that place no constraint on the handler’s return omit the field entirely, so existing consumers of engine::triggers::info are unaffected. In-process (Rust) trigger types can declare their own contract with the new TriggerType::with_call_response_format::<T>() builder.

SDK: inbound unregistertrigger for custom trigger types

When a trigger instance is removed — via trigger.unregister() or because the subscribing worker disconnects — the engine notifies the worker that owns the trigger type so it can run unregisterTrigger and tear down listeners, routes, or subscriptions.Node, Browser, Python, and Rust SDKs already handled inbound registertrigger; they now handle inbound unregistertrigger the same way. Custom trigger type providers (registerTriggerType) receive the binding id (and can look up stored config from their own registry keyed by that id).Built-in trigger types (http, cron, state, subscribe, durable:subscriber, stream, and others) are unchanged: the engine calls each in-process worker’s unregister_trigger directly and never sends a WebSocket message to an SDK worker.

What this fixes

  • Unregistering a trigger bound to a custom trigger type now invokes the provider’s unregisterTrigger callback instead of leaving stale bindings server-side.
  • When the provider worker reconnects, the engine re-sends registertrigger for existing bindings (unchanged); cleanup on consumer disconnect now correctly pairs with unregisterTrigger on the provider.

SDK surface trimming — deprecated and unused exports removed

Breaking (import-time only). A cleanup pass across all three SDKs removed re-exports and aliases that were back-compat shims, orphaned types, or thin wrappers over upstream crates. None change runtime behavior — each is a mechanical import swap.

Observability re-exports dropped (Node + Python)

The Logger and OTel re-exports that iii-sdk kept for back-compat when the observability surface moved to iii-observability in 0.16.0 are now removed. Import from the observability package directly:
The full set removed from the Python iii package: Logger, init_otel, shutdown_otel, flush_otel, with_span, execute_traced_request, OtelConfig, ReconnectionConfig, BaggageSpanProcessor, current_span_id / current_trace_id, current_span_is_recording, record_span_event, set_current_span_attribute / set_current_span_error, the baggage and traceparent inject/extract helpers, redact / redact_and_truncate / resolve_max_bytes_from_env, DEFAULT_ALLOWLIST, and REDACTED_PLACEHOLDER. All live in iii_observability.

Rust SDK: crate-root re-exports and dead types removed

Node SDK: TriggerActionType alias removed

The TriggerActionType type alias is gone — use TriggerAction directly. The TriggerAction.Enqueue() / TriggerAction.Void() runtime helpers are unchanged.

Python SDK: IIIForbiddenError / IIITimeoutError removed

Both exception subclasses are deleted. All rejections — including timeouts and RBAC denials — now raise IIIInvocationError; branch on its .code ("FORBIDDEN", "TIMEOUT") instead of catching distinct types.
June 2026

Channel and stream helpers moved to a helpers submodule

Breaking. createChannel / createStream (and the channel utility types) are no longer instance methods or crate-root exports — they moved to a dedicated helpers submodule across all three SDKs. This keeps the core iii client surface focused on registration and invocation, and groups the channel/stream plumbing in one importable place.
The same submodule now also carries the channel utilities — ChannelDirection, ChannelItem, extractChannelRefs / extract_channel_refs, and isChannelRef / is_channel_ref — which were previously top-level exports. ChannelReader, ChannelWriter, and StreamChannelRef stay at the package root.

iii-worker warns when scripts.install is omitted

A worker manifest with no scripts.install now emits a warning at load time instead of silently skipping the install step, so a missing setup phase is visible during local runs and CI rather than surfacing later as a runtime failure.
June 2026

Observability: getTracer / getMeter / SpanKind dropped from the public Node API

Breaking. @iii-dev/observability no longer exports getTracer, getMeter, or SpanKind from its main entry point. getTracer / getMeter moved to a first-party-only @iii-dev/observability/internal subpath; they were never intended for application code. External consumers should:
  • instrument with withSpan / initOtel, and
  • import SpanKind from @opentelemetry/api directly.

Stored logs are stripped of ANSI escape codes

Log lines captured by the observability pipeline now have terminal color/formatting escape sequences removed before storage, so persisted logs render as clean text in the dashboard and downstream consumers instead of leaking raw \x1b[...m codes.
May 2026

Single register_function entry point in the Rust SDK

Breaking. The Rust SDK’s function registration is collapsed into a single entry point that mirrors Node and Python:
RegisterFunction carries the handler plus all optional metadata. There are three constructors — new, new_async, http — and Value is accepted by new / new_async, so no separate untyped constructor is needed. register_function_with, the tuple form, untyped, IntoFunctionRegistration, IntoFunctionHandler, RegisterFunctionOptions, iii_fn, iii_async_fn, IIIFn, and IIIAsyncFn are removed.Handler error type is fixed to IIIError. IIIError now implements From<String> / From<&str> so existing Result<R, String> handlers can migrate by updating the return type and relying on ?-propagation.See the migration entry for the full before/after diff, builder methods, and step-by-step migration.

Logger and OpenTelemetry primitives moved to iii-observability

The Logger, OtelConfig, ReconnectionConfig (OTel variant), and the full OTel surface (init_otel / shutdown_otel / flush_otel / with_span / execute_traced_request, baggage and traceparent helpers, current_span_id / current_trace_id, span ops, payload redaction, BaggageSpanProcessor) now ship from a new shared package in every supported language:This isolates telemetry concerns from the SDK transport so workers that don’t need OTel pull a smaller dependency set, and so the surface stays consistent across languages.Two helpers that previously only existed in the Rust SDK are now available in Node and Python as well:
  • flush_otel / flushOtel — force-flushes every provider without tearing OTel down. Use it before short-lived process exits where you still need pending spans, metrics, and logs delivered.
  • execute_traced_request / executeTracedRequest — wraps an outgoing HTTP call (httpx in Python, fetch in Node) in an OTel CLIENT span. Injects W3C traceparent, records HTTP semantic-convention attributes, sets ERROR status on >= 400 responses, and records exceptions on network errors.

Migration

Python and Rust continue to re-export the moved symbols from the SDK package for back-compat. Node removes the iii-sdk/telemetry subpath entry point — the named exports from iii-sdk itself stay, so import { Logger } from 'iii-sdk' keeps working. Direct imports from the new packages are preferred:
The new packages publish in lock-step with the rest of the monorepo on the same iii/v* release tag, so versions stay aligned with iii-sdk.

register_service removed from all SDKs

Breaking. register_service / registerService, along with the RegisterServiceInput and RegisterServiceMessage types, are removed from the Node, Browser, Python, and Rust SDKs, and the engine no longer handles the message. Services were an organizational-only grouping that never affected invocation or routing, so there is no replacement — drop all register_service calls.

Unused telemetry accessors removed

Breaking. Alongside the observability move, low-level telemetry accessors that were exported but unused are gone:
  • Node (iii-sdk): getTracer, getMeter, SpanStatusCode — import SpanStatusCode from @opentelemetry/api; tracer and meter are internal.
  • Python (iii): get_tracer, get_meter, is_initialized are now private (_get_tracer, _get_meter, _is_initialized) — use the opentelemetry API directly.
  • Rust (iii_sdk): the get_tracer, get_meter, is_initialized, SpanKind, and SpanStatus re-exports — obtain meters via opentelemetry::global::meter(...) and import SpanKind from opentelemetry::trace.
For custom metrics, use the OpenTelemetry global meter directly rather than the SDK’s getMeter / get_meter.
May 2026

sandbox::run — one call from zero to result

A new meta-function composes sandbox::create + sandbox::fs::write + sandbox::exec + sandbox::stop into a single call. The classic four-step “create → write → exec → stop” dance drops to one. The sandbox is auto-stopped on both success and failure unless you pass keep_sandbox: true.

sandbox::catalog::list

A new function returns the daemon’s image catalog — bundled presets plus operator-registered custom_images entries from iii.config.yaml. Closes the “what images are available on this host?” discovery loop without operator hand-off.

sandbox::exec and sandbox::create accept more input shapes

sandbox::exec.cmd now accepts three shapes:
  • cmd + args (classic POSIX)
  • argv array
  • shell-line cmd (shlex-split when args / argv are empty)
sandbox::exec.env and sandbox::create.env accept either a Vec<"K=V"> list or a { K: V } map. Env-var names are pinned to [A-Za-z_][A-Za-z0-9_]*; digit-leading or //-/= names are rejected as S001.

sandbox::fs::read returns inline bodies for small text

Additive: a new optional body field on the sandbox::fs::read response carries the file contents as a UTF-8 string for text files under 1 MiB that decode cleanly. The existing content: StreamChannelRef field is still always populated and still delivers the same bytes, so peers that statically type content as a stream ref keep working unchanged. New callers can short-circuit the channel subscription whenever body is present:
Cost: small text is buffered into the channel as well as the inline body so legacy subscribers still receive it. Bounded at 1 MiB per call.

Structured sandbox::* errors with resubmittable fix payloads

Every sandbox::* function now returns a structured envelope on failure:
  • docs_url anchors directly at the in-repo S-code subsection. Breaking: the base URL flipped from https://iii.dev/docs/errors/sandbox/Sxxx to https://github.com/iii-hq/iii/blob/main/crates/iii-worker/src/sandbox_daemon/README.md#Sxxx while the canonical iii.dev error pages are still pending. Bookmarks and scrapers built on the old URL need to follow the new anchors.
  • fix is a non-null JSON payload the agent can merge into the original request and resubmit verbatim when recovery is unambiguous (parent-missing writes, sandbox::run sub-step failures, etc.).
  • fix_note describes how to use the fix or — when fix is null — explains why no auto-recovery exists.
  • sandbox::run sub-step failures surface the inner S-code transparently and name the failing step in fix.context, plus fix.sandbox_id when keep_sandbox: true.
  • FS error message strings now carry a kind prefix (e.g. "file not found: {path}" instead of bare {path}). The authoritative code / type fields are unchanged; only callers that grep the message text are affected.

sandbox::exec default timeout raised to 5 minutes

Breaking. The default timeout_ms for sandbox::exec moves from 30 s to 300 s. Sized for cold npm install / pip install / cargo build. Previously the 30 s default fired as an opaque engine-gate denial before the daemon could return a structured timed_out: true response. Callers that relied on the 30 s fast-fail to bound runaway commands should now set timeout_ms explicitly.

Handler-boundary tracing on every sandbox::* handler

Every sandbox::* handler emits a tracing::info! event on both success and error with a stable field set: function_id, sandbox_id, success, error_code, error_type, retryable, duration_ms. Operators can dashboard sandbox usage without grepping unstructured logs.

Telemetry re-exports removed from public SDK surface

Breaking. Convenience re-exports of OpenTelemetry accessors were dropped from the Rust, Node, Python, and browser SDKs. Underlying behavior is unchanged — only the public surface is smaller. Users who need a tracer or meter directly should depend on the OpenTelemetry library for their language.Removed symbols by language:

Migration

  • For custom spans, prefer withSpan / with_span / run_in_span. These preserve trace context.
  • To obtain a tracer or meter directly, depend on @opentelemetry/api (Node) or the opentelemetry crate / Python package and call its accessors. Rust users can also keep using iii::telemetry::get_tracer / iii::telemetry::get_meter.
May 2026

iii sandbox subcommand removed

Breaking. The iii sandbox CLI subcommand is gone. Every sandbox operation now goes through iii trigger:
Each call also accepts a single --json '<obj>' payload (e.g. iii trigger sandbox::exec --json '{"sandbox_id":"…","cmd":"python3","args":["-c","print(2+2)"]}'), equivalent to the kv form shown above.iii trigger is request/response only, so the streaming flows the old subcommand offered (exec stdout/stderr stream, upload, download) are no longer available from the terminal. Use the SDK from worker code for those: sandbox::exec and sandbox::fs::write / sandbox::fs::read still expose the streaming channel.

iii trigger reshape

Breaking. iii trigger no longer accepts --function-id and --payload. The new form takes the function path as a positional argument and accepts payload fields as key=value tokens, an --json '<obj>' flag, or both:
See Triggers for the full reference.

iii update --list-targets

iii update now exposes a --list-targets flag that prints every target accepted by iii update <target> (e.g. self, console, worker). Passing an unknown target now points users at this flag instead of failing silently. Rollback is not supported; reinstall a prior version manually with curl -fsSL https://iii.dev/install.sh | sh -s -- --version <prior>.
April 2026

Migrating from Motia

Breaking. The Motia framework is deprecated in favor of using iii-sdk directly. Moving to the SDK unlocks multi-worker orchestration, browser connectivity via iii-browser-sdk with RBAC, and a direct understanding of iii’s three primitives — Workers, Functions, and Triggers. Your existing Motia project becomes one worker in a larger iii deployment instead of a standalone monolith.Node / TypeScript migration guide → · Python migration guide →

SDK discovery wrappers removed

Breaking. The convenience discovery wrappers were removed from the Node, browser, Rust, and Python SDKs:
  • listFunctions / list_functions / list_functions_async
  • listWorkers / list_workers / list_workers_async
  • listTriggers / list_triggers / list_triggers_async
  • listTriggerTypes / list_trigger_types / list_trigger_types_async
  • onFunctionsAvailable / on_functions_available
Discovery now goes through the core primitives directly: call trigger() against the built-in engine functions and register engine::functions-available like any other trigger type. This keeps the SDK surfaces aligned with the engine’s “use the primitives directly” design.

Worker RBAC

The iii-worker-manager now supports role-based access control. Configure auth functions that validate WebSocket upgrade requests, attach per-session allow/deny lists for functions, control trigger registration, and auto-prefix function IDs for namespace isolation. An optional middleware function lets you intercept every invocation for audit logging, rate limiting, or payload enrichment.Read the Worker RBAC guide →

Trigger format, validation, and metadata

Trigger types now accept trigger_request_format and call_request_format fields (JSON Schema) so the engine can validate trigger configs and call payloads at registration time. Triggers also support an arbitrary metadata field for tagging and filtering.Define request/response formats → · Trigger architecture →

Browser SDK

Your browser is now a first-class iii worker. The new iii-browser-sdk package connects to the engine over a single WebSocket and exposes the same core primitives as the Node SDK — registerFunction, trigger, registerTrigger, and createChannel all work identically. Build real-time dashboards, collaborative apps, and bi-directional frontends without REST endpoints or polling.Use iii in the browser →

Sandbox and Container Workers

Workers can now run as container workers or sandbox workers. Container workers are OCI images managed through the iii worker CLI — add an image, configure it in config.yaml, and the engine pulls, extracts, and runs it in an isolated sandbox. For local development, iii worker add ./my-project registers a local directory as a first-class managed worker that runs inside a lightweight microVM with auto-detected runtimes, dependency caching, and full lifecycle support (start, stop, list, remove) — no Dockerfiles needed. Requires macOS Apple Silicon or Linux with KVM.Managing Container Workers → · Developing Sandbox Workers →

iii worker exec

A new iii worker exec <name> -- <cmd> command runs arbitrary commands inside a running worker’s microVM — think docker exec for iii workers. stdin/stdout/stderr flow through, exit codes pass back, Ctrl-C delivers SIGINT (twice for SIGKILL). TTY mode auto-detects when both stdin and stdout are terminals, so iii worker exec my-worker -- sh in a terminal gives you a real interactive shell with line editing and job control. Pass --timeout 30s to bound runaway commands (exit 124 matches coreutils).Exec into a running worker →

Reproducible worker installs

Registry-managed workers can now be pinned in iii.lock. iii worker add writes the resolved worker graph when the registry provides one, binary workers can record artifacts for multiple platform targets, iii worker verify checks that config.yaml is represented in the lockfile, and iii worker update [worker] refreshes locked pins intentionally.Reproduce Worker Installs →

Topic-based fan-out queues

Breaking. The topic-based queue API has been renamed. The trigger type changes from queue to durable:subscriber, and the publish function changes from enqueue to iii::durable::publish:
Messages now fan out to every subscriber, with each function processing its copy independently and retrying on its own schedule. If a function has multiple replicas, they compete on a shared per-function queue. An optional condition_function_id lets you filter messages server-side before they reach the handler.Use topic-based queues →

Node SDK: registerFunction signature change

Breaking. The registerFunction API now takes the function ID as a plain string instead of an options object:
The options object (metadata, request/response formats) moves to an optional third argument.

Everything is a worker

Breaking. We simplified iii down to three primitives: Workers, Functions, and Triggers. Modules were always workers in disguise — they connect to the engine, register functions, and react to triggers just like SDK workers do. Now the naming reflects that.
  • Config YAML — modules: top-level key renamed to workers:, class: field renamed to name: with short identifiers.
  • Rust API — Module trait → Worker, register_module! → register_worker!, EngineBuilder::add_module() → add_worker().
  • Adapter IDs — changed from long Rust-style paths to short names: kv, redis, builtin, rabbitmq, local, bridge.
Read the full story and migration guide →