Calling Agents with Runtime Reflection (Go)
Normal RPC through the callee’s definition package or a generated guest client (see golem-call-another-agent-go) is the baseline. Reflection adds three more levels, which compose through agent ids:
| Level | Use it when | Go |
|---|---|---|
| Caller-defined, method-only | Many agent types share a method shape; you have an id | golem.DefineAgentClient[Shape]() + Method[In, Out], Bind(id) |
| Caller-defined, full | You own Go types for one target type, without its definition | golem.DefineFullAgentClient[Id](name, spec) + Method[In, Out] |
| Discovered | The type or method is chosen at runtime and should be validated | reflection.DiscoverAgentType → Get / Bind → Call |
| Fully dynamic | Values are already packed; no schema authority kept | reflection.BindAgentID(id) → Call |
Every level uses the same verbs: Call waits for the result, Trigger does not, Schedule runs it later. Discovered and dynamic clients live in the reflection package (github.com/golemcloud/golem/sdks/go/golem/reflection).
Method-Only Clients
A method-only client declares methods and nothing else, so it calls any existing agent that has them, whatever its type:
type Pingable struct{} // the shape's identity type
var (
Pinger = golem.DefineAgentClient[Pingable]()
Ping = Pinger.Method[golem.Unit, string]("ping")
)
func pingAll(ids []string) {
for _, id := range ids {
c, err := Pinger.Bind(id)
if err != nil {
continue
}
reply, err := Ping.Call(c, golem.Unit{})
fmt.Println(id, reply, err)
}
}Bind checks nothing about the target, which the client does not describe; a target that is not deployed is reported by Bind, and a mismatch by the host or when the result is decoded. Binding does not create the agent, but the first call to a durable id does.
Full Clients
A full client also declares the target type’s name, constructor and lifecycle, so it creates and addresses instances like the agent’s own definition:
type CounterID struct{ Name string }
type AddIn struct{ By int64 }
var (
Counter = golem.DefineFullAgentClient[CounterID]("CounterAgent", golem.AgentClientSpec{})
Add = Counter.Method[AddIn, int64]("add")
)
c, err := Counter.Get(CounterID{Name: "main"}) // an error when the target is not deployed
total, err := Add.Call(c, AddIn{By: 5})
c, err = Counter.Bind(agentID) // the id must name CounterAgent with a CounterID constructor
id, err := Counter.AgentID(CounterID{Name: "main"}, golem.None[golem.UUID]()) // without creating itGet,NewPhantom,BindandAgentIDwork the same on an agent’s own definition (DefineAgent), except that thereGet,NewPhantomandAgentIDpanic instead of returning an error: the target is in the same component, so a failure is a programming mistake.golem.AgentClientSpec{Mode: golem.Ephemeral}declares an ephemeral target: it has no durable identity, soGetandBindare refused andNewPhantomaddresses it.golem.DefineConfiguredFullAgentClient[Id, Cfg]declares the target’s configuration type, soGet(id, client.WithConfig(cfg))passes typed creation-time overrides, checked at compile time againstCfg.
Generated guest clients are built on full clients.
Discover and Inspect
for _, t := range reflection.DiscoverAgentTypes() {
fmt.Println(t.Name(), t.Mode(), t.SourceLanguage())
for _, m := range t.Methods() {
fmt.Println(" ", m.Name(), m.Description(), m.ReadOnly())
}
}
counter, found := reflection.DiscoverAgentType("CounterAgent")
if !found {
panic("CounterAgent is not deployed in this environment")
}
add, known := counter.Method("add")
if !known {
panic("CounterAgent has no method add")
}
input := add.Input() // a schema.Ref of the parameter record
fmt.Println(golem.Must(input.ToJSONSchema(true))) // e.g. for an LLMInput() is the parameter record (without host-filled fields such as the principal) and Output() the result type, golem.None when there is none; both are schema.Refs from github.com/golemcloud/golem/sdks/go/core/schema, which pack and unpack canonical JSON and render JSON Schema. Constructor().Input() describes the constructor the same way. A discovery miss (not deployed, not visible to the caller) reports false. A snapshot never refreshes itself; discover again when a newer deployment matters.
Call a Discovered Agent
client, err := counter.Get(map[string]any{"name": "main"})
if err != nil {
panic(err)
}
total, invocation, err := client.Call("add", map[string]any{"by": "5"}) // s64 is a string
if err != nil {
var rce *golem.RemoteCallError
if errors.As(err, &rce) {
fmt.Println(rce.Kind, rce.Message)
}
panic(err)
}
fmt.Println(client.AgentID(), total, invocation.IdempotencyKey)Gettakes the constructor arguments by name and creates the agent if it does not exist;reflection.WithPhantomID(uuid)addresses a phantom instance andNewPhantomallocates one.Bind(agentID)addresses an existing id of this type.reflection.WithConfigJSON(path, value)overrides one declared configuration value at creation, as canonical JSON (reflection.WithConfigValue(path, schema.SchemaValue)takes a schema-model value);Get,NewPhantomandBindaccept it. Undeclared paths, secrets and invalid values are refused before anything is sent. An agent that already exists keeps the configuration it was created with.Call,TriggerandSchedulereturn the invocation’sgolem.InvocationID(agent id and idempotency key) — for an ephemeral agent, the only record of which instance ran.CallAsyncreturns a*reflection.PendingCall: itsIDis known at once,Get()returns the result (MustGet()panics on an error, and a secondGetreturns the same result), andCancel()stops waiting (the call may already have run;Getthen reportsgolem.ErrCallCancelled). It behaves like the*golem.Futurethat a typedCallAsyncreturns. A scheduled call is cancelled through the*golem.ScheduledInvocationthatSchedulereturns.- Missing, unexpected or invalid arguments fail with
*schema.ValidationError, listing every problem, before anything is sent. - Remote failures are
*golem.RemoteCallError; for a remote agent error, itsCauseis a*golem.AgentError.
Agent IDs
typed := golem.Must(golem.ParseAgentID[CounterID](agentID))
fmt.Println(typed.TypeName, typed.ID.Name, typed.PhantomID.IsSome())
raw := golem.Must(reflection.ParseRawAgentID(agentID)) // no Go type needed
fmt.Println(raw.TypeName, golem.Must(raw.ConstructorJSON()))
t, found := reflection.DiscoverAgentTypeByID(agentID) // the type behind an existing agent
id := golem.Must(t.AgentID(map[string]any{"name": "other"}, golem.None[golem.UUID]()))Parsing is strict and reports malformed ids; lookup by id never creates the agent. Agent ids are scoped to the caller’s environment.
Fully Dynamic
A dynamic client sends values the caller packed itself and validates nothing. Keep a discovered snapshot beside it for validation:
t, _ := reflection.DiscoverAgentType(agentType)
m, _ := t.Method(method)
input := golem.Must(m.Input().PackJSON(args))
client := golem.Must(reflection.BindAgentID(agentID))
out, _, err := client.Call(method, input)
if err != nil {
panic(err)
}
if v, has := out.Get(); has {
if result, declared := m.Output().Get(); declared {
fmt.Println(golem.Must(result.UnpackJSON(v)))
}
}A dynamic client also has CallAsync, Trigger and Schedule. reflection.MakeAgentID(typeName, constructor, phantom) builds an id from a packed constructor, so a dynamic caller can address any instance; the first call to it creates a durable agent.
Canonical JSON
- Integers up to 32 bits are JSON numbers;
s64,u64, duration nanoseconds and quantity mantissas are base-10 strings ("5"). A duration is{"nanoseconds": "…"}. - An absent option is
null; a result is{"ok": …}or{"err": …}; a variant case is its name, or{name: payload}; a map is[[k, v], …]; flags are an array of names; binary is unpadded base64url. - Streams, futures and other capabilities cannot be packed as JSON; call such methods through a typed client.
Related Guides
| Guide | Description |
|---|---|
golem-agent-reflection | Choosing between client approaches, identity rules |
golem-call-another-agent-go | Typed RPC through definitions and guest clients |
golem-call-tool-go | Calling tools the same ways |
golem-multi-instance-agent-go | Agent identities and phantoms |