Skip to Content
DevelopAgent Reflection

Agent reflection

Use reflection when your application selects an agent type or method at runtime. You can discover deployed types, inspect and validate their schemas, construct agent IDs, address phantom agents, and invoke methods without importing the target’s definition.

For ordinary application code, prefer Agent-to-Agent communication, including generated guest bridges between components. Reflection is useful for gateways and adapters whose target is not known at build time.

Client approachUse it whenWhat validates the call
Normal RPCThe target is known at build time through a definition or generated guest clientGenerated static codecs validate typed inputs and decode outputs
Caller-defined static clientThe caller knows a stable interface but does not share the producer’s source definitionCaller-owned schemas and codecs validate the fields they describe
Discovered clientThe caller selects an agent, method, tool, or command from the current environmentAn immutable discovery snapshot automatically validates inputs and declared outputs
Fully dynamic clientInfrastructure already transports packed schema-native valuesThe caller explicitly owns packing and validation; the host still validates authorization and the deployed target

Normal RPC is not reflection. It provides the strongest compile-time experience with the least setup, through a source definition or a generated guest client.

Caller-defined static clients are useful for integrations that deliberately own a typed view of another component. A full agent definition can create and identify agents and validate known configuration declarations. A method-only definition can bind an existing durable identity, but cannot validate an identity or configuration declarations it does not describe. Caller-defined tool definitions similarly provide typed access to a selected tool surface.

Discovered clients are the default choice when the deployed environment is the source of truth. Discovery reads visible metadata and returns a client backed by that snapshot. The snapshot does not change under the caller: rediscover explicitly when a newer deployment should be used.

Fully dynamic clients are the lowest-level option. They invoke an existing durable agent identity or an explicit tool command using schema-native values. Use them for gateways, adapters, and other infrastructure that intentionally keeps schema selection, packing, and result validation separate from transport.

The examples below use a durable CounterAgent with constructor field name: string and method add(by: signed 32-bit integer), returning a signed 32-bit integer. Later snippets reuse variables from the discovery example. Run calls inside an agent method; these are guest SDK APIs, not external REST clients.

Discover, inspect, and call an agent

List the visible types or look up one by name. A discovered type exposes its name, description, lifecycle mode, source language, implementing component, constructor schema, methods, and configuration declarations. Each method exposes its name, description, input schema, and optional output schema. Input schemas contain caller-supplied fields, not fields supplied by Golem such as the principal.

Discovered calls validate inputs before RPC and validate declared outputs after the call. A unit-returning method has no output schema or returned value; invocation metadata is still available.

import { getAllAgentTypes, getReflectedAgentType, } from "@golemcloud/golem-ts-sdk" export async function inspectAndCallCounter() { const available = getAllAgentTypes() const counterType = getReflectedAgentType("CounterAgent") if (!counterType || counterType.mode !== "durable") { throw new Error("CounterAgent unavailable or not durable") } const add = counterType.method("add") if (!add) throw new Error("add unavailable") const args = { by: 5 } const checked = add.input.validateJson(args) if (!checked.success) throw new Error(JSON.stringify(checked.issues)) console.log(available.map(type => type.name), add.input.toJsonSchema()) const client = counterType.client.get({ name: "main" }) const result = await client.method("add").invoke(args) console.log(result.value, result.metadata.agentId) return result }

Metadata method(name) and type discovery return undefined on a miss. Inspect constructorInput, methods, config, and method input/output schemas. Invalid schema operations and calls throw.

Caller-defined static clients

A full client describes the type name, constructor, lifecycle, and methods using caller-owned codecs. A method-only client describes just methods and binds a durable agent ID supplied by the caller. Neither performs discovery. Method-only clients cannot validate constructor or configuration declarations they do not describe.

import { defineAgentClient, method } from "@golemcloud/golem-ts-sdk" import { z } from "zod" export const Counter = defineAgentClient({ name: "CounterAgent", id: { name: z.string() }, methods: { add: method({ input: { by: z.int32() }, returns: z.int32() }), }, }) export const CounterMethods = defineAgentClient({ methods: Counter.methods }) export async function callCounter() { const id = Counter.agentId({ name: "main" }) const full = id.client(Counter) const methodOnly = id.client(CounterMethods) return [await full.add({ by: 5 }), await methodOnly.add({ by: 5 })] }

Construct, parse, and bind agent IDs

An ID contains the agent type name, constructor values, and optional phantom ID. Constructing or parsing it does not invoke the agent. Binding prepares a client; a later invocation reaches the durable agent and creates it if necessary. Discovery by ID is a separate lookup and does not create an agent.

Use a discovered type or full client to construct an ID with schema validation. Raw ID constructors are for infrastructure that already owns packed constructor values. A full or discovered client checks the type name and constructor when binding; a method-only or fully dynamic client has no such local schema authority.

import { ParsedAgentId, getAgentTypeByAgentId } from "@golemcloud/golem-ts-sdk" const id = counterType.agentId({ name: "main" }) const parsed = new ParsedAgentId(id.value) const { typeName, constructorValue, phantomId } = parsed.parts() const currentType = getAgentTypeByAgentId(parsed) if (!currentType) throw new Error("Agent or type unavailable") const rebound = parsed.client(currentType)

Wrapping a string does not parse it; parts() performs strict parsing. For packed values, use ParsedAgentId.create({ typeName, constructorValue, phantomId }).

Phantom and ephemeral agents

A durable phantom has its own reusable ID. Allocate a new phantom, save its phantom ID, or address an existing phantom using that ID and the same constructor values.

An ephemeral client is a logical handle for one-shot invocations, not a reusable final agent identity. Use its fresh or known-phantom factory rather than ordinary durable binding. Invocation metadata records the final agent ID and idempotency key. Do not rebind a completed ephemeral agent ID for another call.

const fresh = counterType.client.newPhantom({ name: "main" }) if (!("client" in fresh)) throw new Error("Expected a durable phantom") const known = counterType.client.getPhantom({ name: "main" }, fresh.phantomId) const result = await known.method("add").invoke({ by: 5 })

For an ephemeral discovered type, newPhantom(input) returns the client directly instead of a { client, agentId, phantomId } bundle. getPhantom(input, phantomId) addresses a known phantom. For example:

const requestType = getReflectedAgentType("RequestAgent") if (!requestType || requestType.mode !== "ephemeral") { throw new Error("RequestAgent unavailable or not ephemeral") } const request = requestType.client.newPhantom({ route: "summarize" }) if ("client" in request) throw new Error("Expected an ephemeral client") const result = await request.method("run").invoke({ text: "hello" }) console.log(result.metadata.agentId, result.metadata.idempotencyKey)

This assumes RequestAgent has constructor field route and a run(text) method.

Creation-time configuration

You can override declared local configuration values, not secrets. Unknown paths and invalid values fail before RPC. Overrides apply only when the durable agent is created; calling an existing agent does not replace its persisted configuration.

These examples assume that CounterAgent declares a local signed 32-bit value at limits.max.

const configured = counterType.client.get( { name: "configured" }, [{ path: ["limits", "max"], value: 10 }], )

For native overrides, use getValue with typed entries. packConfigJson converts JSON entries; validateConfigValues checks native entries. Existing IDs bind with id.client(counterType, entries).

Trigger, schedule, and cancel calls

Awaited calls return results and invocation metadata. Triggered calls return metadata without waiting for a result. Scheduled calls return a cancellation handle as well as metadata. Metadata contains the target agent ID and idempotency key.

These examples use an application-supplied absolute time at. Cancelling observation of a running call does not undo remote side effects or prove that the invocation never ran.

const method = client.method("add") const triggered = method.trigger({ by: 5 }) const scheduled = method.schedule(at, { by: 5 }) scheduled.cancellationToken.cancel() const abort = new AbortController() const pending = method.invoke({ by: 5 }, abort.signal) abort.abort() try { await pending } catch (error) { console.error(error) }

at has seconds: bigint and nanoseconds: number. An AbortSignal cancels an awaited call’s observation. Inspect structured remote errors with isRemoteCallError, not by parsing messages.

Lend authority to an awaited call

An awaited reflected call can borrow a scope card derived during the current invocation. The card extends the callee’s authority for that call only. Triggered and scheduled calls cannot borrow scope cards. See Permission cards for deriving and managing cards; the snippets below assume an existing scope card.

const result = await client.method("add").invoke({ by: 5 }, undefined, scopeCard)

The second argument is an optional AbortSignal. Pass null as the scope-card argument to suppress a client’s default scope card.

Fully dynamic calls and native values

Fully dynamic clients bind an ID without discovering a schema or validating inputs and outputs against one. Keep a discovered snapshot alongside the client when you want explicit validation. Packing input with that snapshot does not attach its output-validation policy to the dynamic client.

These examples pack add’s JSON input, invoke dynamically, and validate and decode the native result. For streaming methods, use native value APIs, transfer owned inputs only once, and consume or close returned streams before releasing the client or scope. JSON helpers cannot transport streams, futures, or opaque capabilities. Prefer a typed client when the streaming contract is known at build time.

const id = counterType.agentId({ name: "main" }) const input = add.input.packJson({ by: 5 }) const result = await id.dynamicClient().method("add").invokeValue(input) if (!add.output || result.value === undefined) { throw new Error("add must return a value") } const checked = add.output.validateValue(result.value) if (!checked.success) throw new Error(JSON.stringify(checked.issues)) const total = add.output.unpackJson(result.value)

Dynamic methods also expose triggerValue and scheduleValue.

Reflected non-awaited calls have additional restrictions for live streams. Do not use the JSON, trigger, or schedule snippets above as streaming examples; use awaited native calls and follow the SDK’s resource rules.

Discover and call tools

Reflection also exposes native tools. A tool contains a command tree, not agent methods. Inspect the selected command’s arguments, input schema, result schema, and stream requirements. A namespace can contain subcommands without having a callable body.

These examples assume a visible tool named reports with a callable summary command that accepts name and requires no input or output streams. Tool errors remain structured and are distinct from remote agent errors.

import { getReflectedToolType } from "@golemcloud/golem-ts-sdk" const reports = getReflectedToolType("reports") const summary = reports?.command(["summary"]) if (!summary?.inputSchema) throw new Error("summary command unavailable") console.log(summary.inputSchema.toJsonSchema()) const result = await summary.invokeJson({ name: "main" })

Use invokeValue for native values. startJson/startValue expose separate stdout, stderr, result, collection, and cancellation handles; consume or cancel a started operation. For caller-owned codecs and fully dynamic tool clients, see Calling tools.

Validation boundaries

Static and discovered clients validate what they know before opening RPC. Discovered schemas include numeric and text restrictions, nested structures, defaults, and tool command constraints. Reflected JSON helpers provide canonical JSON conversion, and JSON Schema projections make the same shape available to JSON-based integrations. An omitted optional record field and an explicit null both decode as absent; re-encoding may include the field with null, and optional fields are omitted from JSON Schema required.

Canonical JSON uses numbers for integers through 32 bits. Signed and unsigned 64-bit integers are canonical decimal strings. Duration is { "nanoseconds": "..." }, and a quantity’s mantissa is also a signed decimal string. A leading +, leading zeroes, -0, the wrong JSON type, and values outside the declared integer range are rejected. Reflection JSON Schema uses matching string patterns and exact string-valued bounds.

Streams, futures, and opaque capabilities cannot be represented as reflected JSON and must use schema-native value APIs. Their reflection-specific JSON Schema projection is unsatisfiable.

The host remains authoritative for visibility, authorization, effective configuration, durable identity resolution, and the deployed target schema. Awaited calls also verify whether an output is required and whether the returned value matches the declared schema. Remote agent failures, tool failures, and custom error payloads remain structured so callers can handle them without parsing messages.

Configuration overrides apply when an agent is created. Rebinding an existing durable identity does not reconfigure that agent; it keeps its persisted initial configuration.

Further examples

For concrete APIs and language recipes, see the common reflection guide and the SDK guides for Rust, TypeScript, Effect, Scala, MoonBit, and Go.