Skip to Content
How-To GuidesRustCalling Agents with Runtime Reflection (Rust)

Calling Agents with Runtime Reflection (Rust)

Choose the narrowest of four client approaches:

Client approachAuthorityUse it when
Normal RPCA source definition shared through Rust’s ordinary RPC mechanismProducer and caller share a compile-time definition
Caller-defined static clientsCaller-owned Rust types and codecsThe caller owns a stable typed subset without discovery
Discovered clientsThe environment’s immutable metadata snapshotTypes or methods are selected at runtime and need validation or JSON projection
Fully dynamic clientsManually packed SchemaValue / TypedSchemaValueInfrastructure deliberately retains no schema authority

A method-only agent client binds an existing durable identity and never creates or discovers one. A full client declares the type, constructor, lifecycle, and optional config, so it owns durable, phantom, and ephemeral creation. Dynamic agent clients start from an existing durable identity; the explicit ephemeral constructor is a one-shot invocation address whose final identity comes from invocation metadata. Tool namespaces are discoverable but only nodes with command bodies are callable.

Validation and Optional Values

Validation happens at three different boundaries:

BoundaryWhat Rust validatesWhat remains authoritative remotely
Definition/buildSchema well-formedness, duplicate caller-owned tool paths, command-tree restrictionsWhether an optimistic caller-defined target is deployed
Pack/create/startJSON shape, exact integer range, constructor/config shape, required stdin/stdout/stderr, defaults, Present, nested ValueIs, and command constraintsAuthorization, registered lifecycle, effective config and secrets
CompletionResult cardinality, resolved graph equivalence, declared custom-error payloads, and value decodingExecution failures and undeclared remote errors

Normal RPC and caller-defined Rust values use Option<T>. Reflected canonical JSON treats an omitted optional record field or an explicit null as absent; re-encoding may include that field with null, and reflection JSON Schema omits it from required. When using SchemaValue directly, use SchemaValue::Option { inner: None } or SchemaValue::Option { inner: Some(Box::new(value)) }. Optional tool scalar positionals and options use that option carrier. Repeatable/tail inputs use an empty list for absence, flags use their effective boolean/count value, and an explicit default is represented by the default value. Do not replace an absent optional carrier with a zero or empty string.

Canonical JSON represents s64, u64, duration nanoseconds, and quantity mantissas as canonical base-10 strings. Smaller integers remain JSON numbers. Packing rejects leading +, non-canonical leading zeroes, and out-of-range values before transport; projected JSON Schema uses the same string patterns and exposes the exact range as metadata.

Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves.

Discover Agent Types

Discover the agent types registered in the current environment:

use golem_rust::{get_agent_type, get_all_agent_types}; let available = get_all_agent_types()?; let counter_type = get_agent_type("CounterAgent")?; println!("{} {:?}", counter_type.name(), counter_type.mode()); for method in counter_type.methods() { println!("{}", method.name()); }

Agent type names are environment-unique. AgentType exposes the current implementation component, lifecycle mode, constructor schema, and method schemas. Discovery is authoritative for reflected calls; it does not modify a caller-owned definition or retry a call after schema changes. Rust lookup helpers are strict: a missing type is returned as GolemReflectError::AgentTypeNotFound.

Inspect and Pack Schemas

Constructor and method schemas are SchemaRef values. They validate SchemaValue, pack canonical JSON, unpack results, and render JSON Schema:

use serde_json::json; let method = counter_type.method("add")?; let input = method.input().pack_json(&json!({ "by": 5 }))?; let json_schema = method.input().to_json_schema(true);

Use invoke_json for automatic JSON packing and unpacking. Use explicit pack_json, invoke_value, and unpack_json when the boundary needs access to schema-native values.

Invoke Through Reflected Schemas

Create a durable client from the reflected type and invoke a method selected by name:

use serde_json::json; let counter = counter_type.client().get_json(&json!({ "name": "main" }))?; let result = counter .method("add")? .invoke_json(&json!({ "by": 5 })) .await?; println!("{:?}", result.value); println!("{}", result.metadata.agent_id.as_str());

Creation-time overrides can be supplied as canonical JSON with get_json_with_config(&constructor_json, entries) or as schema-native AgentConfigValue entries with get_value_with_config. Reflected types also expose bind_with_json_config and bind_with_config for existing identities. They reject undeclared paths, secret fields, and invalid values before constructing RPC. Component defaults may satisfy required local fields; the host validates effective config at creation and provisions secrets. An existing durable worker retains its persisted initial config even when a caller supplies overrides while binding its ID.

Reflected methods also expose trigger_value, pending_value, and schedule_value. Pending invocations are futures and can be cancelled before completion. Scheduled invocations return a cancellation token. Live stream inputs and outputs are supported by awaited invoke_value calls. They are not supported by trigger or scheduled calls; a pending call cannot accept a live stream input.

Define a Caller-Owned Typed Client

#[agent_client] has two forms. A trait containing only instance methods is a method-only client: it can bind any existing durable ParsedAgentId, and the identity supplies the remote type name and constructor value. The trait’s Rust IntoSchema and FromSchema codecs are the method-schema authority:

use golem_rust::{ParsedAgentId, agent_client}; #[agent_client] trait CounterApi { fn add(&self, by: u32) -> u32; fn reset(&self); } fn bind(agent_id: &ParsedAgentId) -> Result<CounterApiClient, golem_rust::GolemReflectError> { CounterApiClient::bind(agent_id) }

Method-only clients do not declare a name, constructor, mode, or config and do not discover the remote lifecycle. Statically named methods are awaited calls. The client also generates trigger_<method>, pending_<method>, and schedule_<method>. Binding checks no constructor shape because the method-only client does not own one. Programmatic method-only definitions accept raw typed entries through bind_with_entries(agent_id, entries) without declaration-aware validation.

A fully defined client declares exactly one static constructor returning Self and must also declare type_name. Either half without the other is a compile error. Constructor parameters use the same encoding and #[agent_config] rules as #[agent_definition]:

#[agent_client(type_name = "CounterAgent")] trait CounterApi { fn new( name: String, #[agent_config] config: golem_rust::agentic::Config<CounterConfig>, ) -> Self; fn add(&self, by: u32) -> u32; } let ordinary = CounterApiClient::get("main".to_string())?; let known = CounterApiClient::get_phantom(phantom_id, "main".to_string())?; let fresh = CounterApiClient::new_phantom("main".to_string())?; let bound = CounterApiClient::bind(&agent_id)?;

Fully defined durable binding checks both the declared type name and the locally declared constructor shape before creating RPC transport. A fully defined ephemeral client uses mode = "ephemeral", supports fresh and known phantom factories, and deliberately has no generic ParsedAgentId binding.

The programmatic equivalent starts by selecting the method-only form:

let method_only = MethodOnlyAgentClientDefinition::builder() .method_only() .method::<AddInput, u32>("add")? .build(); let full = MethodOnlyAgentClientDefinition::builder() .durable::<CounterId>("CounterAgent") .config::<CounterConfig>() .method::<AddInput, u32>("add")? .build(); let constructor = CounterId { name: "main".to_string() }; let agent_id = full.agent_id(&constructor, None)?; let bound = full.bind(&agent_id)?; let ordinary = full.get(&constructor)?; let known = full.get_phantom(phantom_id, &constructor)?; let fresh = full.new_phantom(&constructor)?; let configured = full.get_with_config( &constructor, CounterConfigRpc { model: Some("fast".to_string()) }, )?; let ephemeral = MethodOnlyAgentClientDefinition::builder() .ephemeral::<CounterId>("CounterRequest") .method::<AddInput, u32>("add")? .build(); let logical = ephemeral.new_phantom(&constructor)?; let known_ephemeral = ephemeral.get_phantom(phantom_id, &constructor)?;

Only fully defined durable clients expose bind. It checks the declared agent name and validates the parsed constructor value against CounterId; an ID with a different constructor shape is rejected before transport creation. Config-aware factories exist only after .config::<C>(), and accept C::RpcType, the typed override carrier generated by ConfigSchema. They also provide bind_with_config(agent_id, config) for fully defined durable clients. Every method on a fully defined caller-authored #[agent_client] reports invocation failures through Result; ordinary RPC clients keep their established call signatures. Malformed declared remote outputs, including unexpected unit values on awaited or pending calls, report GolemReflectError::MalformedRemoteOutput.

Construct and Bind Agent IDs

When reflected metadata is available, let the reflected constructor schema validate the identity:

use serde_json::json; let agent_id = counter_type.agent_id_json(&json!({ "name": "main" }), None)?; let reflected = agent_id.reflected_client(&counter_type)?; let typed = CounterApiClient::bind(&agent_id)?;

get_agent_type_for(&agent_id) resolves the currently registered type for an existing identity. It does not create the agent.

For fully dynamic infrastructure, construct an environment-scoped ID from a type name, manually packed constructor record, and optional phantom UUID:

use golem_rust::{ParsedAgentId, SchemaValue}; let agent_id = ParsedAgentId::from_value( "CounterAgent", SchemaValue::Record { fields: vec![SchemaValue::String("main".to_string())], }, None, )?;

This path performs no local schema or lifecycle verification. Constructor record fields must be in declaration order; the runtime authoritatively accepts or rejects the attempted identity or invocation.

Invoke Direct Schema Values

Use dynamic_client only when the caller intentionally owns manually packed Golem values and arbitrary method names:

use golem_rust::SchemaValue; let result = agent_id .dynamic_client()? .method("add") .invoke_value(SchemaValue::Record { fields: vec![SchemaValue::U32(5)], }) .await?;

Dynamic methods also support trigger_value, pending_value, and schedule_value. They never use caller-authored or reflected schemas.

Discovery to a Fully Dynamic Agent

Keep the discovered schemas beside the dynamic client and apply their policy explicitly. Moving a packed value into DynamicAgentClient does not make that client validate it:

use golem_rust::{AgentMode, GolemReflectError, get_agent_type}; use serde_json::{Value, json}; async fn search_dynamically() -> Result<Value, GolemReflectError> { let agent_type = get_agent_type("SearchAgent")?; if agent_type.mode() != AgentMode::Durable { return Err(GolemReflectError::InvalidType( "SearchAgent must be durable".to_string(), )); } let method = agent_type.method("search")?; let input = method .input() .pack_json(&json!({ "query": "golem", "cursor": null }))?; let agent_id = agent_type.agent_id_json(&json!({ "tenant": "docs" }), None)?; let result = match agent_id .dynamic_client()? .method(method.name()) .invoke_value(input) .await { Ok(result) => result, Err(error) => { eprintln!("dynamic search failed: {error}"); return Err(error); } }; let output = method.output().ok_or_else(|| { GolemReflectError::MalformedRemoteOutput( "search unexpectedly declares unit output".to_string(), ) })?; let value = result.value.ok_or_else(|| { GolemReflectError::MalformedRemoteOutput( "search returned unit instead of a value".to_string(), ) })?; output.validate_value(&value)?; output.unpack_json(&value) }

This awaited example owns no stream or pending handle. If packed values contain owned streams, transfer each input once and consume or close every returned stream; cancel or await any pending invocation before abandoning it.

Discover and Call Tools

Tool discovery returns one immutable registration snapshot. Walk root() or node() to show every namespace and its documentation; check is_callable() before converting a node to a ToolCommand:

Rust deliberately keeps metadata-only ToolNode separate from callable ToolCommand. Tool reflection uses ToolReflectionError so metadata and local input failures remain distinct from the nested transport and remote ToolError; agent schema helpers continue to use GolemReflectError.

use golem_rust::{SchemaValue, get_tool_type}; async fn run() -> Result<(), Box<dyn std::error::Error>> { let tool = get_tool_type("search")?; for child in tool.root().children() { println!("{} callable={}", child.name(), child.is_callable()); } let command = tool.node(&["documents", "find"]) .ok_or("missing command")? .command()?; let result = command.invoke_value(SchemaValue::Record { fields: vec![ SchemaValue::String("golem".to_string()), SchemaValue::Option { inner: None }, ], }).await?; println!("{result:?}"); Ok(()) }

invoke_value is the simple awaited path. For independently consumed stdout or stderr, call start_value; it consumes the optional stdin carrier and returns a ToolInvocation containing both output readers plus cancellation/result handles. Move each declared output to a concurrent consumer or call collect(), which drives stdout, stderr, and structured completion together. collect() reports the structured result error first when multiple channels fail. Call cancel() when abandoning work and close() on an output when abandoning only that attachment. A declared custom error is returned as ToolError::Tool(ReflectedToolCustomError) with its typed payload; structural host failures and undeclared custom errors remain distinct.

Use ToolClientDefinition for a caller-owned typed subset without discovery:

use golem_rust::{FromSchema, IntoSchema, ToolClientDefinition}; #[derive(IntoSchema)] struct SearchInput { query: String, limit: Option<u32>, } #[derive(IntoSchema, FromSchema)] struct SearchOutput { hits: Vec<String>, } async fn search() -> Result<Vec<String>, Box<dyn std::error::Error>> { let definition = ToolClientDefinition::unnamed() .command::<SearchInput, SearchOutput, _, _>(["search"])? .build()?; let client = definition.client_for("document-search")?; let command = client.command::<SearchInput, SearchOutput>(&["search"])?; let output = command.invoke(&SearchInput { query: "golem".to_string(), limit: None, }).await?; Ok(output.hits) }

client() uses the definition’s declared name; client_for() supplies a name for an unnamed reusable definition. Both are fallible. They validate the local typed schema before transport creation and invocation but intentionally do not discover the remote tool. Use DynamicToolClient only for already packed TypedSchemaValue inputs and explicit command paths.

Phantom and Ephemeral Lifecycles

Reflected durable factories support normal lookup, known phantom UUIDs, and new phantom UUIDs. A new durable phantom returns its client, reusable agent ID, and phantom UUID together.

Create ephemeral agents through a full reflected or caller-owned client. A reflected factory returns a one-shot client without a reusable pre-invocation identity:

use golem_rust::{NewPhantomClient, get_agent_type}; use serde_json::json; let request_type = get_agent_type("RequestAgent")?; let NewPhantomClient::Ephemeral(request) = request_type .client() .new_phantom_json(&json!({ "task": "summarize" }))? else { return Err("RequestAgent must be ephemeral".into()); }; assert!(request.agent_id().is_none()); let result = request .method("run") .invoke_value(SchemaValue::Record { fields: vec![SchemaValue::String("hello".to_string())], }) .await?; println!("{}", result.metadata.agent_id.as_str());

No reusable pre-invocation identity is guaranteed for an ephemeral call. The final identity comes from invocation metadata and cannot be resumed or invoked again.

Choose an Invocation Path

SituationUse
Shared source definition availableNormal RPC through <Agent>Client
Caller-owned typed method subsetmethod-only or full #[agent_client]
Type or method selected at runtime with JSONget_agent_type plus invoke_json
Explicit runtime-schema packingSchemaRef::pack_json, invoke_value, unpack_json
Fully dynamic infrastructure with packed Golem valuesParsedAgentId::from_value and dynamic_client