Calling Another Agent (RPC) in Go
Overview
Agents talk to each other through the callee’s definition package. You get a typed client from Agent.Get(id) and call a method descriptor on it. The caller imports only the callee’s definition package (its ID, its method descriptors) — never its impl — so the two implementations never form a Go import cycle.
Calls are durable: results are recorded in the oplog and replayed after a restart rather than re-issued.
Steps
- Import the callee’s definition package (e.g.
myapp/agents/ledger). - Get a client for a specific instance:
ledger.Agent.Get(ledger.ID{Region: "eu"}). - Call a method descriptor on the client:
ledger.Record.MustCall(client, ledger.RecordIn{Amount: 10}), orCallto get(Out, error)back. - If the method returns a
golem.Result, unwrap it (.MustOk()or inspect it).
Synchronous call
package impl
import (
"myapp/agents/ledger"
"myapp/agents/rpccaller"
"github.com/golemcloud/golem/sdks/go/golem"
)
type state struct{}
var agent = rpccaller.Agent.Implement(func(rpccaller.ID) *state { return &state{} })
func init() {
agent.Handle(rpccaller.Call, func(_ *golem.Context[state], in rpccaller.CallIn) int64 {
// Get a client for the ledger instance identified by region.
c := ledger.Agent.Get(ledger.ID{Region: in.Region})
// MustCall returns the method's Out; here Out is a golem.Result, unwrapped with MustOk.
return ledger.Record.MustCall(c, ledger.RecordIn{Amount: in.Amount}).MustOk()
})
}Call and MustCall block until the callee returns. Because a single agent processes invocations sequentially, a synchronous self-call (calling back into the same instance) would deadlock — use Trigger/Schedule for self-messaging (see golem-recurring-task-go).
Plain vs Result returns
MustCall returns the method’s declared output type directly. Only unwrap when that type is a golem.Result — don’t append .MustOk() to a plain return:
// greeter.Hello returns a plain string → use it directly
greeting := greeter.Hello.MustCall(c, greeter.HelloIn{Name: "alice"})
// ledger.Record returns golem.Result[int64, string] → unwrap
total := ledger.Record.MustCall(c, ledger.RecordIn{Amount: 10}).MustOk()Handling a failed call
Call returns (Out, error). The error is a *golem.RemoteCallError when the callee failed, could not be reached after the runtime’s retries, or returned a value that does not decode as Out. Its Kind classifies the failure, and a remote custom error stays reachable through errors.As:
total, err := ledger.Record.Call(c, ledger.RecordIn{Amount: 10})
if err != nil {
var remote *golem.RemoteCallError
if errors.As(err, &remote) && remote.Kind == golem.RemoteNotFound {
return "the ledger is not deployed"
}
panic(err)
}MustCall panics with the same error instead. A panic in a method traps the component, as in Rust: the calling agent fails, or, inside durability.Atomically, the region is retried.
Asynchronous call
Start the call, do other work, then await:
fut := ledger.Record.CallAsync(c, ledger.RecordIn{Amount: in.Amount}) // *golem.Future[Out]
// ... other work ...
total := fut.MustGet().MustOk() // MustGet() blocks for the result; Get() returns (Out, error)Launch several and await them to fan out work across other agents.
Unwrapping a golem.Result
When a method’s output is golem.Result[Ok, Err]:
res := ledger.Record.MustCall(c, in) // golem.Result[int64, string]
switch {
case res.IsErr():
// handle the domain error value
msg := res.Err()
_ = msg
default:
total := res.Ok()
_ = total
}
// Or fail loud (panic on Err, which fails the calling agent):
total := ledger.Record.MustCall(c, in).MustOk()Import-cycle rule (important)
- impl packages never import each other. All cross-agent references go through the callee’s definition package, which imports only the SDK.
- This keeps the import graph a DAG no matter how cyclic the call graph is (A↔B mutual calls are fine as long as each impl imports the other’s def, not its impl).
Calling an agent in another component
The definition package only exists inside the component that defines the agent. To call an agent that lives in another component — in any language — use the guest client golem build generates for it.
Declare the dependency on the calling component in golem.yaml:
components:
example:caller:
dir: caller
templates: go
dependencies:
agents:
- example:weather/WeatherAgentgolem build then generates golem-temp/bridge-sdk/go/internal/weather-agent-guest-client, a Go module named golem.local/bridge/weather-agent-guest-client. Point the calling component’s go.mod at it:
require golem.local/bridge/weather-agent-guest-client v0.0.0
replace golem.local/bridge/weather-agent-guest-client => ../golem-temp/bridge-sdk/go/internal/weather-agent-guest-clientImport it and call through the typed client:
import weather "golem.local/bridge/weather-agent-guest-client"
client, err := weather.GetWeatherAgent(weather.WeatherAgentID{City: "London"})
if err != nil {
return err.Error() // the weather component is not deployed
}
forecast, err := client.Forecast(3) // (Out, error)
forecast = client.MustForecast(3) // panics on a failed call
receipt := client.MustTriggerForecast(3) // enqueue, don't wait
pending := client.ForecastAsync(3) // *golem.Future[Out]Get<Agent>andNewPhantom<Agent>return an error when the host cannot resolve the target, as the callee lives in another component that may not be deployed.MustGet<Agent>andMustNewPhantom<Agent>panic instead.- Each method has the forms of a same-component
MethodDef:<M>(MethodDef.Call:(Out, error), onlyerrorwithout output),Trigger<M>,Schedule<M>(at, …), each with aMustform, and<M>Async(CallAsync). A failed call returns the SDK’s own*golem.RemoteCallError, exactly as a same-component call does. Methods that take or return a stream have no trigger or schedule form. - The
.localmodule path can never be fetched from a module proxy, so a missingreplacefails the build rather than silently resolving something else. - The client’s types are the callee’s, generated from its schema: records become structs, variants sealed interfaces with one type per case, enums integer constants with a
String()method. - Methods that take or return streams are included, with each stream spelled
golem.AgentStream[T](seegolem-streaming-agent-go). Get<Agent>andNewPhantom<Agent>takegolem.ClientOpts. When the callee declares local configuration, the client also has a<Agent>Configstruct, with onevalues.Optionfield per config path (["limits", "max-items"]→LimitsMaxItems), and aWith<Agent>Configoption. Only the fields you set are sent; the rest keep their provisioned values:
counter, err := counters.GetCounterAgent(counters.CounterAgentID{Name: "fast"},
counters.WithCounterAgentConfig(counters.CounterAgentConfig{Step: golem.Some[uint32](5)}))Key Constraints
- You call
Method.Call(client, in)(notclient.Call(method, in)): the method descriptor carries the input/output types. Same forTrigger,Schedule,CallAsyncand theMustforms. - The client comes from the definition (
Def.Agent.Get(id)); the ID is encoded from theIDtype, so the caller does not need the callee locally registered. - Don’t hold a
*golem.Futureacross invocations — await it (Get()/MustGet()) within the same handler.
Related Guides
| Guide | Description |
|---|---|
golem-fire-and-forget-go | Enqueue a call without awaiting a result (Trigger) |
golem-recurring-task-go | An agent messages itself / schedules future work |
golem-multi-instance-agent-go | Address a specific instance (or a phantom) by ID |
golem-add-agent-go | Define the agent being called |
golem-agent-reflection-go | Call agents without their Go definition (runtime discovery, remote declarations, agent ids) |