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:HTTP trigger unregister is owner-aware
When two workers registered the samemethod + 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’sdetails.hintnames theworker::schemacall that returns the request schema. W101 and W102 are now reserved (documented but never emitted) — consumers matchingW101for malformed payloads should matchW105. worker::*op failures now surface the W-code as the transportErrorBody.code(previously the generic"invocation_failed", with the W-code only inside the message envelope). Consumers that matchedcode == "invocation_failed"to detect worker-op failures should match the W-code instead.
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.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: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
unregisterTriggercallback instead of leaving stale bindings server-side. - When the provider worker reconnects, the engine re-sends
registertriggerfor existing bindings (unchanged); cleanup on consumer disconnect now correctly pairs withunregisterTriggeron 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)
TheLogger 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: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
Removed from iii_sdk | Replacement |
|---|---|
Value (re-export of serde_json::Value) | depend on serde_json and use serde_json::Value |
UpdateBuilder | build a Vec<UpdateOp> with UpdateOp::set / increment / decrement / append / remove / merge |
FieldPath | UpdateOp path fields now take impl Into<String> — pass String / &str directly |
MergePath (crate root) | still available at iii_sdk::types::MergePath |
TriggerTypeInfo | none — it was orphaned and never wired to anything |
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.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.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.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
SpanKindfrom@opentelemetry/apidirectly.
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.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:| Language | Package | Import |
|---|---|---|
| Node | @iii-dev/observability (npm) | import { Logger, initOtel, withSpan, executeTracedRequest } from '@iii-dev/observability' |
| Python | iii-observability (PyPI) | from iii_observability import Logger, init_otel, with_span, execute_traced_request |
| Rust | iii-observability (crates.io) | use iii_observability::{Logger, init_otel, with_span, execute_traced_request}; |
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,fetchin Node) in an OTelCLIENTspan. Injects W3C traceparent, records HTTP semantic-convention attributes, setsERRORstatus on>= 400responses, 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 theiii-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: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— importSpanStatusCodefrom@opentelemetry/api; tracer and meter are internal. - Python (
iii):get_tracer,get_meter,is_initializedare now private (_get_tracer,_get_meter,_is_initialized) — use theopentelemetryAPI directly. - Rust (
iii_sdk): theget_tracer,get_meter,is_initialized,SpanKind, andSpanStatusre-exports — obtain meters viaopentelemetry::global::meter(...)and importSpanKindfromopentelemetry::trace.
getMeter / get_meter.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)argvarray- shell-line
cmd(shlex-split whenargs/argvare 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:Structured sandbox::* errors with resubmittable fix payloads
Every sandbox::* function now returns a structured envelope on failure:docs_urlanchors directly at the in-repoS-code subsection. Breaking: the base URL flipped fromhttps://iii.dev/docs/errors/sandbox/Sxxxtohttps://github.com/iii-hq/iii/blob/main/crates/iii-worker/src/sandbox_daemon/README.md#Sxxxwhile the canonicaliii.deverror pages are still pending. Bookmarks and scrapers built on the old URL need to follow the new anchors.fixis a non-null JSON payload the agent can merge into the original request and resubmit verbatim when recovery is unambiguous (parent-missing writes,sandbox::runsub-step failures, etc.).fix_notedescribes how to use the fix or — whenfixisnull— explains why no auto-recovery exists.sandbox::runsub-step failures surface the innerS-code transparently and name the failing step infix.context, plusfix.sandbox_idwhenkeep_sandbox: true.- FS error
messagestrings now carry a kind prefix (e.g."file not found: {path}"instead of bare{path}). The authoritativecode/typefields 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:| Symbol | Rust (iii::*) | Node (iii-sdk/telemetry) | Python (iii.telemetry / iii.logger) | Browser |
|---|---|---|---|---|
get_tracer / getTracer | dropped (still at iii::telemetry::get_tracer) | dropped | renamed _get_tracer | already absent (asserted) |
get_meter / getMeter | dropped (still at iii::telemetry::get_meter) | dropped | renamed _get_meter | already absent (asserted) |
is_initialized | dropped (still at iii::telemetry::is_initialized) | n/a | renamed _is_initialized | already absent (asserted) |
SpanKind | dropped (use opentelemetry::trace::SpanKind) | n/a | n/a | already absent (asserted) |
SpanStatus / SpanStatusCode | dropped (use opentelemetry::trace::Status) | dropped | n/a | already absent (asserted) |
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 theopentelemetrycrate / Python package and call its accessors. Rust users can also keep usingiii::telemetry::get_tracer/iii::telemetry::get_meter.
iii sandbox subcommand removed
Breaking. The iii sandbox CLI subcommand is gone. Every sandbox operation now goes through iii trigger:--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: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>.Migrating from Motia
Breaking. The Motia framework is deprecated in favor of usingiii-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_asynclistWorkers/list_workers/list_workers_asynclistTriggers/list_triggers/list_triggers_asynclistTriggerTypes/list_trigger_types/list_trigger_types_asynconFunctionsAvailable/on_functions_available
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 accepttrigger_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 newiii-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 theiii 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 iniii.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 fromqueue to durable:subscriber, and the publish function changes from enqueue to iii::durable::publish: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: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 toworkers:,class:field renamed toname:with short identifiers. - Rust API —
Moduletrait →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.