Skip to main content
iii 0.26 has workers, functions, and observers. iii.observe(a, b) means: when a runs, b runs. That is how a worker reacts to anything, whether a is a plain function, a cron tick, an HTTP request, a queue message, or a row change.
  • Triggers are removed. Every trigger type is an observable function (cron is cron::on-tick), and every trigger is an observer of that function. The trigger’s config becomes the observer’s args, or its filters for a function that takes no args.
  • call invokes a function: iii.call in TypeScript, Python, and Rust (call_async in async Python), client.Call in Go, and iii call in the CLI.
  • The worker that hosts a function changes its defaults with four hooks. The observer lifecycle hooks onObserverAdded and onObserverRemoved (for one function or every function of the worker) run as observers come and go. The observation lifecycle hooks onObservationMatch and onObservationDeliver (for one function) run per call, for each observation: the call reaching, or being considered for, one observer. Together they take over from trigger handlers and from 0.25 owner delivery (delivery: "owner", obs.emit, obs.call).

Before you upgrade

Compatibility

The 0.26 engine and SDKs negotiate the observe.capture feature (see Feature negotiation). Without it on both sides, the legacy on semantics apply. They are deprecated, and support will be removed in a future release: upgrade the engine first, then move every worker to a 0.26 SDK. 0.25 engines and SDKs remain interoperable with 0.26 engines and SDKs; however, the 0.26 SDKs no longer have the on and deliver fields on observe(). The observers of a worker that keeps its 0.25 SDK keep working unchanged (the table above lists what else changes for such a worker, such as dropped trigger registrations), but moving a worker to a 0.26 SDK means replacing on and deliver with capture in the same change (see Observer on and deliver). An observer from a worker on a 0.25 SDK or a 0.26 release candidate SDK fires on success unless its source sets on, even when its capture names error. Set on: "error" or on: "complete" on such an observer until the worker moves to a 0.26 SDK. A worker that binds with registerTrigger receives no events on a 0.26 engine, and the 0.26 releases of the http, cron, queue, state, and database workers need a 0.26 engine. Upgrade the engine, the iii workers your project runs, and every worker that binds to them in one release. The worker gets no error for a dropped registration. The engine logs one warn per connection and message type, such as dropping `registertrigger` frames from this worker: this engine has no such message, so search the engine log for dropping to find a worker that still binds the old way.

Find what to change

Search each worker for the removed APIs:
Then work through the sections below:
  1. Call functions with call.
  2. Move triggers to observers in every worker that binds functions.
  3. Move a trigger type to an observable function in every worker that provides one.
  4. Move an owner-delivered function to hooks for functions registered with observe: { delivery: "owner" }.
  5. Update engine configuration, RBAC, and tooling.

What changed

Calling functions

Each 0.25 name stays as an alias of the 0.26 one: the same function or type, marked deprecated where the language supports it, with no runtime warning. The wire field action is unchanged.
In Rust, trigger, TriggerRequest, TriggerRequestWithMetadata, and TriggerAction carry #[deprecated(since = "0.26.0")]. A crate built with -D warnings or #![deny(warnings)] fails to compile until it renames them to call, CallRequest, CallRequestWithMetadata, and CallAction, or allows them with #[allow(deprecated)].

Binding a function

Implement Rust hooks with native async fn

Implement AddedHook, RemovedHook, and DeliverHook with native async fn, without #[async_trait] or #[async_trait::async_trait] on their implementations. Their public traits use return-position impl Trait in traits (RPITIT) to require a Send future:
The hook receives Observer, Call, and Vec<Observer> by value. The returned future may borrow self across .await: its + '_ lifetime is tied to that call’s &self, not required to be 'static. The hook type itself still implements Send + Sync + 'static. Keep the future Send, including every value held across .await; for example, release a std::sync::MutexGuard before awaiting another operation. These public traits are not dyn-compatible. Pass a concrete implementation or an async closure to the hook registration methods, not Box<dyn AddedHook>, Arc<dyn RemovedHook>, or another hook trait object. Dynamic dispatch and Box::pin(async move { hook_call.await }) belong only to the SDK’s private adapters; hook implementations do not need to box their futures. Closures remain supported. For shared state, use a Clone struct containing an Arc, as in the Timers and Commands examples below, or a closure that captures an Arc and clones it into each async move block. There is no forwarding blanket implementation for Arc<T>, because it would overlap with the blanket implementations for Fn closures. This change applies only to the new observer hooks. The legacy IStream trait is unchanged and still uses #[async_trait]; keep that macro on its implementations. The Rust iii-sdk dependency on async-trait also remains. The macros in the Before (0.25) examples below describe the old APIs and are intentionally retained.

Trigger types and their functions

The http route config renames two fields: api_path is path and http_method is method. Any other trigger type T of a worker W becomes a function W::on-<event>, listed in that worker’s README at workers.iii.dev. The console’s asset types are the exception to that rule. A worker that injects console UI observes ade::ui::asset once per asset, with the asset’s kind and the trigger’s path as args and its content function as the target. The console calls the target with { path } and serves the { content, content_type? } it returns. A later observer of the same path replaces the earlier one. The console worker is published as ade, and its functions use the ade:: namespace: call ade::status, ade::workspace::open and the rest of what was console::* under the new name. The old IDs have no aliases, so update allowlists and agent tool lists with them. The iii::console::* stream topics, the console configuration entry and the console/... asset paths keep their names.

Providing a source

Hooks and options in each SDK. Added and removed are observer lifecycle hooks; match and delivery are observation lifecycle hooks, which run per call: In Python, Rust and Go, set hooks by id before you register the function, so they run for every observer. In Rust, remove .observe(Observe::auto()) from a function that sets no handler: every function is observable, so it has no replacement. For the model, see Observable functions.

Other removed SDK names

Engine, protocol, and configuration

A compose file that still lists iii-sandbox under engine.workers fails iii compose --up with SANDBOX_CONFIG_MOVED, and a config.yaml that lists it makes the engine refuse to start with UNSUPPORTED_CONFIG_WORKERS. Upgrading to the sandbox worker covers the move, any entry stored under iii-sandbox, and the binaries and VM state that 0.25 left behind. An rbac config that sets on_trigger_registration_function_id without on_observer_registration_function_id, or on_trigger_type_registration_function_id without on_function_registration_function_id, makes the engine refuse to start, with an error that names the replacement key. Setting the replacement too turns the old key into a warn at load, and the engine ignores it. The engine ignores allowed_trigger_types and allow_trigger_type_registration in an auth function’s result, and logs one warn per engine process the first time a result sets them. The configuration worker accepts ttl_seconds so an existing config loads, and logs a warn at startup when it is not 0. An entry persists until its file is removed: the fs adapter keeps each entry in <id>.yaml under its directory, and removing that file replays as a configuration:deleted event. The worker registers no function that deletes an entry; behind the bridge adapter, remove the file on the source engine. Remove ttl_seconds from the config.

Call functions with call

Before (0.25)
After (0.26)

Move triggers to observers

A trigger names a type, a function, and a config. An observer names the type’s function with the config as args, and the same target. Register the target first; until it is registered, the observer stays pending. A trigger handed its target the event itself. When iii delivers a call, an observer’s target receives the Observed payload: the ids, status and timings, plus the fields the observer’s capture names. The event is the call’s input, so a trigger’s handler reads it from the payload’s input, and capture: ["input"] carries the event alone. A source that delivers its calls itself, such as http::on-request or ade::ui::asset, decides what its targets receive. An observer runs its target after successful calls of the observed function, like a trigger. To run it after failed calls too, capture error as well; see capture. This cron trigger runs reports::daily-summary every day at 09:00, with metadata for the target. With capture: ["input"], the target receives the tick as the payload’s input:
Before (0.25)
After (0.26)
The other built-in types follow the same shape:
Do not pass a configuration trigger’s config as args. configuration::on-updated declares no args_format, so the engine accepts the observer, ignores the args, and runs the target for a change to any entry. Without the input.event_type filter, the target also runs when an entry is registered or deleted. A durable:subscriber target had 30 minutes per message. A queue::on-message target has observers.activation_timeout_ms (30 seconds by default), and a slower one fails the delivery with TIMEOUT, which retries the message and then dead-letters it. Raise it in the engine config before you move a slow consumer. A cron trigger skipped a tick while its previous run was still going. An observer of cron::on-tick receives every tick, so a slow target can run again before its previous run ends, up to the observer’s max_in_flight (64 by default). With max_in_flight: 1, ticks run one at a time, and a tick that arrives during a run waits in the observer’s queue instead of being skipped. A condition_function_id that you can state over the call becomes a filter, evaluated without an extra function call. A condition that let only gold customers through becomes filters: { "input.customer_tier": { $eq: "gold" } }; see the filter language. A condition that needs code, such as a lookup or an auth check, moves to the start of the target. Any function is observable: iii.observe("orders::create", "alerts::big-order") runs alerts::big-order after every successful call of orders::create, with no event source in between. See Observers.

Move a trigger type to an observable function

A trigger type becomes a function that returns the event it is called with. Its worker keeps the same jobs in hooks: set something up when an observer is added, tear it down when the observer is removed, and say which observers want each call. The engine stores the observers, validates their args, applies their filters and policy, and replays them when the worker reconnects.
  1. Pick a function id for the event, by convention <worker>::on-<event>, and register it with a handler that returns its input. Set private: true, so only your worker can make the event.
  2. Describe the config your trigger type accepted as args_format. Move a declared trigger_request_format to args_format and call_request_format to request_format and response_format.
  3. Move registerTrigger(config) to onObserverAdded(obs) and unregisterTrigger(config) to onObserverRemoved(obs). Read the config from obs.args.
  4. Replace the loop that fired each binding with one call of your function per event. If every observer wants every event, iii’s default delivery is enough. If observers want different events depending on their args, say which in onObservationMatch: a predicate, or keys when the function has many observers.
  5. Stop registering the trigger type, and tell consumers the function id and its args.
  6. Run one instance of the worker for the function. A function id has one host per namespace, so the engine refuses a second registration with FUNCTION_NAMESPACE_CONFLICT, and the SDK does not try again. Keep the host running and let the platform restart it: the engine hands the function’s pending observers to the next worker that registers it.
The example moves a timer trigger type, which calls each binding every every_ms milliseconds. After the move, the worker runs one timer per distinct interval, however many observers share it, and its onObservationMatch predicate gives each tick to the observers of its interval.
Before (0.25)
After (0.26)
A consumer that bound registerTrigger({ type: "timer", function_id, config: { every_ms } }) observes the function:
  • The engine validates args against args_format before any hook runs, and rejects an observer whose args do not match with INVALID_ARGS. To reject an observer for another reason, throw ObserveRejection(code, message) from onObserverAdded; the consumer’s observer turns error with your code.
  • An observer becomes active once onObserverAdded resolves, so the timer exists before the first tick it should see. onObserverRemoved runs exactly once for every observer that was accepted.
  • The SDK runs a function’s hooks one at a time, in the order the engine announced the observers.
  • A worker’s call to its own private function passes the input and the result by reference. Treat both as read-only.
  • To keep a resource open while anything observes any function of the worker, use the worker-wide hooks: iii.onObserverAdded(() => events.retain()) and iii.onObserverRemoved(() => events.release()), where events is that resource, such as a connection to an upstream event stream.

Move an owner-delivered function to hooks

In 0.25, a function registered with observe: { delivery: "owner" } received its own calls, picked observers from its own index, and handed each event over with obs.emit or obs.call. In 0.26 the function’s handler returns the call’s value, onObservationMatch decides which observations each call makes (which observers get it), and onObservationDeliver carries out an observation: it hands the call to an observer and returns that observer’s answer to the caller: The example moves chat::on-command, a router that hands each chat command to the observer registered for it and returns that observer’s reply. Each observer gives one command in its args ({ command }), and a second observer of the same command is rejected with COMMAND_TAKEN.
Before (0.25)
After (0.26)
Consumers observe the router as before, and the router’s worker calls it with call:
iii.call({ observer, payload }) sends the payload as given, applies the observer’s policy (max_fires, expiry, max_in_flight), adds its metadata, and enforces the loop guard. It fails with the target’s own error, or with OBSERVER_NOT_FOUND, NOT_FUNCTION_OWNER, OBSERVER_NOT_ACTIVE, LOOP_GUARD, OVERFLOW, or TIMEOUT. Only the worker that hosts the observed function may call through its observers. An owner-delivered function that handed events to observers chosen by their args with obs.emit moves the way the timer above does: call the function with the event, and say which observers want it with onObservationMatch. With many observers, give it keys ({ observer, call }), and iii keeps the index.

Observer on and deliver

In 0.26, an observer’s capture decides both the outcomes that run its target and what the payload carries (see capture). The 0.25 source fields on and deliver are not part of the 0.26 SDKs: observe() has no on or deliver field, so a worker moved to a 0.26 SDK must use capture instead. Workers that stay on a 0.25 SDK keep sending them, and a 0.26 engine does not refuse them: it translates them to their 0.25 behavior when it registers the observer, and logs one warn per registration that names the observer, the fields, and the capture to use instead. An observer from a worker on a 0.25 SDK or a 0.26 release candidate SDK always gets the 0.25 semantics, even without on or deliver. A value the engine does not know, such as on: "bogus", is refused with INVALID_ARGS. The exact rules are in Legacy on and deliver. Move each observer to capture: The default capture is ["input", "output", "caller"], as in 0.25. The call’s metadata reaches the target only when capture names it. The Observed payload omits a field the call does not have instead of sending null. On 0.25, an observer that captured output got "output": null after a successful call that reported no output; on 0.26 that payload has no output.

From a 0.26 release candidate

  • The default capture is ["input", "output", "caller"]. Earlier release candidates of this change added metadata to the default; add metadata to capture where a target reads payload.metadata. Route on call metadata in onObservationMatch or onObservationDeliver, which receive it whatever the observers capture, or filter on metadata.*, which works without capturing it.
  • A 0.26 engine translates on and deliver from release candidate SDKs with a warning instead of refusing them with INVALID_ARGS. The 0.26 SDKs no longer have these fields on observe(): replace them with capture when you move a worker to a 0.26 SDK.
  • The engine and the 0.26 SDKs add the observe.capture feature. Observer views and engine::observers::list carry fires_on, the outcomes that fire each observer; the numeric fires in engine::observers::list is still the number of activations.
  • The engine’s own observers that hand their target the bare input capture ["input"] only.
  • Rust code that embeds the engine library: FunctionHandler::handle_function, the HandlerFn type, and Message::InvokeFunction take the call’s metadata as Option<Arc<Value>> instead of Option<Value>.

Checklist

  • Upgrade the engine and the iii workers your project runs in one release, together with every worker that registers triggers, trigger types, or functions with observe: { delivery: "owner" }; other workers keep working on a 0.25 SDK until you move them
  • Replace trigger, trigger_async, Trigger, TriggerRequest, TriggerAction, and iii trigger with call, call_async, Call, CallRequest, CallAction, and iii call
  • Replace every registerTrigger with iii.observe of the type’s function, and every unregister() with dispose()
  • Replace on and deliver in observer sources with capture, which decides the outcomes that fire, when you move the worker to a 0.26 SDK: the 0.26 SDKs no longer have these fields on observe(). Workers still on a 0.25 SDK keep working, since a 0.26 engine translates the fields with a warning, but that support will be removed in a future release. See Observer on and deliver
  • Read the event from the payload’s input in targets that expected a trigger’s event, and set capture: ["input"] on their observers. A source that delivers its calls itself, such as http::on-request or ade::ui::asset, decides what its targets receive: follow its documentation instead
  • Move each configuration trigger’s configuration_id and event_types to filters on input.id and input.event_type
  • Move console:script, console:style, and console:module registrations to observers of ade::ui::asset
  • Turn each condition_function_id into filters, or move the check into the target
  • Move your own trigger types to functions with args_format, private: true, and hooks
  • Run one instance of each worker that hosts a function, and let the platform restart it: a second instance is refused the function with FUNCTION_NAMESPACE_CONFLICT and is not retried
  • Move functions registered with observe: { delivery: "owner" } to onObservationMatch, onObservationDeliver, and iii.call({ observer, payload })
  • Replace observe: { allow: false } with observable: false
  • Remove the RBAC trigger keys from the engine config, and grant sessions the functions they observe
  • Replace engine::triggers::* and engine::registered-triggers::* in scripts and dashboards with engine::functions::* and engine::observers::*
  • Move iii-sandbox out of engine.workers and config.yaml, and remove iii-worker and iii-init, as in Upgrading to the sandbox worker
For the observer model, see Observers. For the four hooks and private functions, see Observable functions. For the wire protocol, see Engine protocol.