HostToolRelay
How an agent that works in a sandbox calls the host’s tools. The agent is given the relay as an
MCP server on the sandbox’s loopback, and a token of its own for each session. The tools the
server lists are those of the turn the session runs, and each call the agent makes becomes a
tool-call part of that turn, which the host runs as it runs any host tool and answers with
submitToolResult. The relay answers the agent’s request with the result. While it waits, for
the host or for a person’s approval, it keeps the request alive with progress notifications,
which an agent that has a time limit for a request takes as a sign of life.
Only the host runs the tools, so nothing they need reaches the sandbox. A session’s token lets the agent ask the host to run that session’s tools, which the host’s toolkit and approvals still decide.
make is the relay without a transport: it answers the MCP messages it is given. layer serves
it over the HttpServer provided, and is what a bridge program uses. A call waits for the host
for as long as an approval takes, so the server must not close a connection for being idle, as
Bun’s does after ten seconds unless its idleTimeout is 0.
Since v0.1.0
Constructors
Section titled “Constructors”The relay without a transport, which answers MCP messages. layer serves it over HTTP; a bridge
program with a transport of its own gives answer each message it receives.
Signature
declare const make: (options?: Options | undefined) => Effect.Effect<Relay, never, Crypto.Crypto>Since v0.1.0
Layers
Section titled “Layers”Serves the relay over the HttpServer provided, at /mcp on that server’s own address, as a
bridge program does beside its bridge.
Example (Serving the relay from a bridge program on Bun)
import { HostToolRelay } from "@amends-dev/harness"import { BunHttpServer } from "@effect/platform-bun"import { Layer } from "effect"
// A call waits for the host, so the server must not close idle connections.const RelayLayer = HostToolRelay.layer().pipe( Layer.provide(BunHttpServer.layer({ hostname: "127.0.0.1", port: 0, idleTimeout: 0 })))Signature
declare const layer: (options?: Options) => Layer.Layer<HostToolRelay, never, HttpServer.HttpServer | Crypto.Crypto>Since v0.1.0
Models
Section titled “Models”Answer
Section titled “Answer”Signature
declare const Answer: { readonly Unauthorized: Data.TaggedEnum.ConstructorFrom<{ readonly _tag: "Unauthorized" }, "_tag"> readonly Accepted: Data.TaggedEnum.ConstructorFrom<{ readonly _tag: "Accepted" }, "_tag"> readonly Reply: Data.TaggedEnum.ConstructorFrom<{ readonly _tag: "Reply"; readonly message: Schema.Json }, "_tag"> readonly Events: Data.TaggedEnum.ConstructorFrom< { readonly _tag: "Events"; readonly messages: Stream.Stream<Schema.Json> }, "_tag" > readonly $is: <Tag extends "Unauthorized" | "Accepted" | "Reply" | "Events">( tag: Tag ) => ( u: unknown ) => u is | Extract<{ readonly _tag: "Unauthorized" }, { readonly _tag: Tag }> | Extract<{ readonly _tag: "Accepted" }, { readonly _tag: Tag }> | Extract<{ readonly _tag: "Reply"; readonly message: Schema.Json }, { readonly _tag: Tag }> | Extract<{ readonly _tag: "Events"; readonly messages: Stream.Stream<Schema.Json> }, { readonly _tag: Tag }> readonly $match: { < Cases extends { readonly Unauthorized: (args: { readonly _tag: "Unauthorized" }) => any readonly Accepted: (args: { readonly _tag: "Accepted" }) => any readonly Reply: (args: { readonly _tag: "Reply"; readonly message: Schema.Json }) => any readonly Events: (args: { readonly _tag: "Events"; readonly messages: Stream.Stream<Schema.Json> }) => any } >( cases: Cases ): ( value: | { readonly _tag: "Unauthorized" } | { readonly _tag: "Accepted" } | { readonly _tag: "Reply"; readonly message: Schema.Json } | { readonly _tag: "Events"; readonly messages: Stream.Stream<Schema.Json> } ) => Unify<ReturnType<Cases["Unauthorized" | "Accepted" | "Reply" | "Events"]>> < Cases extends { readonly Unauthorized: (args: { readonly _tag: "Unauthorized" }) => any readonly Accepted: (args: { readonly _tag: "Accepted" }) => any readonly Reply: (args: { readonly _tag: "Reply"; readonly message: Schema.Json }) => any readonly Events: (args: { readonly _tag: "Events"; readonly messages: Stream.Stream<Schema.Json> }) => any } >( value: | { readonly _tag: "Unauthorized" } | { readonly _tag: "Accepted" } | { readonly _tag: "Reply"; readonly message: Schema.Json } | { readonly _tag: "Events"; readonly messages: Stream.Stream<Schema.Json> }, cases: Cases ): Unify<ReturnType<Cases["Unauthorized" | "Accepted" | "Reply" | "Events"]>> }}Since v0.1.0
Answer (type alias)
Section titled “Answer (type alias)”What the relay answers one MCP message with.
Signature
type Answer = Data.TaggedEnum<{ /** The message names no session the relay has. */ Unauthorized: {}
/** A notification, which has no reply. */ Accepted: {}
Reply: { readonly message: Schema.Json }
/** The messages a call is answered with, as they happen: its progress, and then its result. */ Events: { readonly messages: Stream.Stream<Schema.Json> }}>Since v0.1.0
Relay (interface)
Section titled “Relay (interface)”The relay without a transport, as make returns it: it starts sessions and answers the MCP
messages a transport gives it.
Signature
export interface Relay { /** Starts a session, which lasts until the scope given closes. */ readonly open: (id: Agent.SessionId) => Effect.Effect<RelaySession, never, Scope.Scope>
/** * Answers an MCP message, as the HTTP request that carried it, with its `Authorization` header * and its body, is answered. */ readonly answer: (authorization: string | undefined, message: unknown) => Effect.Effect<Answer>}Since v0.1.0
RelaySession (interface)
Section titled “RelaySession (interface)”A session of an agent, as far as the relay knows it: the token the agent presents, the tools of its running turn, and the results of the calls it made.
Signature
export interface RelaySession { /** What the agent's MCP client presents as `Authorization: Bearer`. It names this session. */ readonly token: Redacted.Redacted<string>
/** * Offers `tools` to the agent for the turn that the scope given runs, and returns the parts that * stream the calls the agent makes of them, until the scope closes. A call the agent makes when * no turn runs, or of a tool the turn did not offer, fails. The tools stay listed after the turn. */ readonly offerTools: ( tools: ReadonlyArray<Bridge.ToolDefinition> ) => Effect.Effect<Stream.Stream<StreamPart.StreamPart>, never, Scope.Scope>
/** * Gives the agent the result of a call it made. A result for a call the agent has given up on, or * that the end of the turn ended, is dropped, since nothing waits for it. */ readonly submitToolResult: Agent.AgentSession["submitToolResult"]}Since v0.1.0
Options
Section titled “Options”Options (interface)
Section titled “Options (interface)”How long the relay keeps a call alive, and how long it waits for the host.
Signature
export interface Options { /** How often a request that waits for the host hears from the relay. Ten seconds by default. */ readonly heartbeat?: Duration.Input
/** * The longest the relay waits for the host to answer a call, which an approval a person has not * given yet counts in. The agent reads the call as failed after it. An hour by default. */ readonly maxWait?: Duration.Input}Since v0.1.0
Services
Section titled “Services”HostToolRelay (class)
Section titled “HostToolRelay (class)”The relay as a bridge program serves it, for a driver to give its agent: where the agent’s MCP client connects, and the sessions the driver opens.
Example (A driver giving its agent the host’s tools through the relay)
import { Bridge, HostToolRelay, StreamPart } from "@amends-dev/harness"import { Effect, Redacted, Stream } from "effect"
// What the adapter has of its agent: it is an MCP client over HTTP, configured per session.declare const agent: { readonly createSession: (options: { readonly directory: string readonly mcpServers: Record<string, { url: string; headers: Record<string, string> }> }) => Effect.Effect<string> readonly prompt: (sessionId: string, text: string) => Stream.Stream<StreamPart.StreamPart>}
const makeDriver = Effect.gen(function* () { const relay = yield* HostToolRelay.HostToolRelay const driver: Bridge.Driver = { id: "my-agent", createSession: (options) => Effect.gen(function* () { const relaySession = yield* relay.open(options.id) const sessionId = yield* agent.createSession({ directory: options.directory, mcpServers: { host: { url: relay.url, headers: { authorization: `Bearer ${Redacted.value(relaySession.token)}` } } } }) const session: Bridge.DriverSession = { keptConversation: false, // The turn streams the agent's parts and the calls it makes of the host's tools. promptTurn: (turn) => Stream.unwrap( Effect.map(relaySession.offerTools(turn.tools), (calls) => { const parts = agent.prompt(sessionId, JSON.stringify(turn.prompt.content)) return Stream.merge(parts, calls, { haltStrategy: "left" }) }) ).pipe(Stream.scoped), submitToolResult: relaySession.submitToolResult, approve: () => Effect.void, compact: () => Effect.die("my-agent compacts only by itself"), state: Effect.succeed(sessionId), destroy: Effect.void } return session }) } return driver})Signature
declare class HostToolRelaySince v0.1.0