Skip to content

RelayTool

Go · package github.com/muthuishere/toolnexus/golang · SPEC §10 addendum · golang/relay.go

const RelayKind = "tool_call" // Request.Kind for a relayed call
const RelayOutputKey = "output" // Answer.Data key: the host's tool output
const RelayIsErrorKey = "isError" // Answer.Data key: whether the host's tool failed
type RelayCall struct {
ID string // the provider's tool-call id — the tool_result correlation key
Name string // the tool the model called
Input map[string]any // parsed arguments
Arguments string // the RAW arguments JSON, so an OpenAI-shaped caller can echo it
}
func RelayTool(name, description string, schema JSONSchema) Tool
func IsRelayRequest(req *Request) bool
func RelayCallsOf(req *Request) []RelayCall

RelayTool builds a declaration-only Tool (Source: SourceRelay): it carries a schema, but nothing runs in toolnexus when the model calls it. Instead, the call suspends with Request.Kind == "tool_call", carrying every relay call from that assistant turn (in tool-call order) under Request.Data["calls"] — read them with RelayCallsOf. The host executes each call itself and hands back the result as an Answer; the loop resumes and feeds each output back as that call’s tool_result, exactly as if the tool had run locally. This is what lets toolnexus act as a pure proxy while still declaring standard OpenAI-shaped function calling to the provider.

Reach for RelayTool when toolnexus owns the conversation loop (unlike Translate, which owns neither loop nor state) but the execution of a specific tool belongs to the caller — a coding-agent client that runs shell commands itself, a browser extension that clicks buttons in a real tab, anything where “the tool” is inherently outside the process running toolnexus. Declare it, let the model call it, and resume with the caller’s own output via RunWithAnswer/AskWithAnswer.

1. The smallest useful call — the relay call reaches the host, nothing runs locally

Section titled “1. The smallest useful call — the relay call reaches the host, nothing runs locally”

The model asks for lookup; the suspension carries the call; WaitFor supplies the output; the loop feeds it back as the tool’s result — and the run finishes normally, Status == "done".

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
turn := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
turn++
w.Header().Set("Content-Type", "application/json")
if turn == 1 {
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"weather\"}"}}]}}],"usage":{"prompt_tokens":4,"completion_tokens":4,"total_tokens":8}}`))
return
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"it is sunny"}}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`))
}))
defer srv.Close()
lookup := toolnexus.RelayTool("lookup", "look something up", toolnexus.JSONSchema{
"type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}},
})
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{lookup}})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
var got toolnexus.Request
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k",
WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) {
got = req
return toolnexus.Answer{ID: req.ID, Ok: true, Data: map[string]any{toolnexus.RelayOutputKey: "sunny, 31C"}}, nil
},
})
res, err := client.Run(context.Background(), "weather?", tk)
if err != nil {
log.Fatal(err)
}
if res.Status != "done" || res.Text != "it is sunny" {
log.Fatalf("unexpected: %+v", res)
}
if !toolnexus.IsRelayRequest(&got) {
log.Fatalf("expected a relay request, got kind=%q", got.Kind)
}
calls := toolnexus.RelayCallsOf(&got)
if len(calls) != 1 || calls[0].ID != "c1" || calls[0].Name != "lookup" || calls[0].Input["q"] != "weather" {
log.Fatalf("unexpected relay call: %+v", calls)
}
if res.ToolCalls[0].Output != "sunny, 31C" || res.ToolCalls[0].IsError {
log.Fatalf("expected the host's output to become the tool result: %+v", res.ToolCalls)
}
fmt.Println("ok:", res.Text, "- relay call was", calls[0].Name+"("+calls[0].Arguments+")")
}

2. The realistic case — three relay calls in one turn ride a single suspension

Section titled “2. The realistic case — three relay calls in one turn ride a single suspension”

Request.Data["calls"] grows to carry ALL of a turn’s relay calls, in tool-call order — the first-in-order halt rule is unchanged; only the surfaced request’s payload grows. This mirrors OpenAI’s tool_calls array one-to-one, so a caller never sees a truncated call list.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[` +
`{"id":"c1","type":"function","function":{"name":"alpha","arguments":"{}"}},` +
`{"id":"c2","type":"function","function":{"name":"beta","arguments":"{}"}},` +
`{"id":"c3","type":"function","function":{"name":"gamma","arguments":"{}"}}` +
`]}}],"usage":{"prompt_tokens":4,"completion_tokens":4,"total_tokens":8}}`))
}))
defer srv.Close()
relay := func(name string) toolnexus.Tool { return toolnexus.RelayTool(name, "relayed to the caller", nil) }
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false, ExtraTools: []toolnexus.Tool{relay("alpha"), relay("beta"), relay("gamma")},
})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
// No WaitFor: the run halts durably, and the SINGLE surfaced request carries all 3 calls.
client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"})
res, err := client.Run(context.Background(), "go", tk)
if err != nil {
log.Fatal(err)
}
if res.Status != "pending" || res.Pending == nil {
log.Fatalf("expected a durable halt, got status=%q", res.Status)
}
calls := toolnexus.RelayCallsOf(res.Pending)
if len(calls) != 3 {
log.Fatalf("expected all 3 relay calls on ONE surfaced request, got %d: %+v", len(calls), calls)
}
for i, want := range []string{"alpha", "beta", "gamma"} {
if calls[i].Name != want {
log.Fatalf("call %d = %q, want %q (tool-call order preserved)", i, calls[i].Name, want)
}
}
fmt.Println("ok:", len(calls), "relay calls rode one suspension, in order:", calls[0].Name, calls[1].Name, calls[2].Name)
}

3. The full surface — a real tool runs alongside a relay tool, and a declined relay does not abort the run

Section titled “3. The full surface — a real tool runs alongside a relay tool, and a declined relay does not abort the run”

Relay composes with ordinary executing tools in the same turn (only the relay one suspends), and a declined relay call becomes an error tool_result — the loop continues, exactly like any other declined suspension.

package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
toolnexus "github.com/muthuishere/toolnexus/golang"
)
func main() {
realRan := false
real := toolnexus.Tool{
Name: "real_tool", Description: "runs locally", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}},
Source: toolnexus.SourceCustom,
Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) {
realRan = true
return toolnexus.ToolResult{Output: "real-output"}, nil
},
}
relayed := toolnexus.RelayTool("relayed_tool", "runs at the host", nil)
tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{
Builtins: false, ExtraTools: []toolnexus.Tool{real, relayed},
})
if err != nil {
log.Fatal(err)
}
defer tk.Close()
turn := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
turn++
w.Header().Set("Content-Type", "application/json")
switch turn {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"real_tool","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c2","type":"function","function":{"name":"relayed_tool","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`))
default:
_, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"handled the decline"}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`))
}
}))
defer srv.Close()
client := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k",
WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) {
// The caller declines the relayed call — the run must not abort.
return toolnexus.Answer{ID: req.ID, Ok: false, Reason: "declined"}, nil
},
})
res, err := client.Run(context.Background(), "do both", tk)
if err != nil {
log.Fatal(err)
}
if !realRan {
log.Fatal("expected the ordinary tool to actually run")
}
if res.Status != "done" {
log.Fatalf("a declined relay must not abort the run: %+v", res)
}
fmt.Println("ok: real tool ran locally, relay decline was handled without aborting:", res.Text)
}
Aspect Detail
Request.Kind "tool_call" (RelayKind) — a use of the suspension primitive, not a second mechanism.
Request.Data["calls"] []RelayCall{id, name, input, arguments}; id is stamped by the loop (a tool cannot know its own call id).
Multiple calls, one turn All of one assistant turn’s relay calls ride the single surfaced request, in tool-call order — the first-in-order halt rule is unchanged.
Resolution Answer.Data["results"][]{id, output, isError}, one per call. A single-call relay may use the shorthand Answer.Data["output"]/Answer.Data["isError"].
Declined (Ok: false) Feeds back an error tool_result for that call; the run continues either way — a relayed failure is never an aborted run.
Not a tool error isError:false + pending:true on the tool observability event; no afterTool failure path. Relaying is normal operation for a translator.
Collision guard Toolkit construction fails if a relay tool’s name collides with a builtin’s — unconditionally, even with builtins disabled.
Absent No relay tool declared ⇒ not one observable difference from a plain toolkit.
  • Client.ResumeRunWithAnswer/AskWithAnswer: the durable resolution path a relay suspension typically uses.
  • Pending — Return a Pending from a tool to park the run until someone answers — the primitive relay is built on.
  • WaitFor — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
  • Translate — The stateless, no-loop alternative for the caller-owns-everything posture.