Skip to content

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

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>

Source

Since v0.1.0

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>

Source

Since v0.1.0

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"]>>
}
}

Source

Since v0.1.0

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> }
}>

Source

Since v0.1.0

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>
}

Source

Since v0.1.0

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"]
}

Source

Since v0.1.0

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
}

Source

Since v0.1.0

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 HostToolRelay

Source

Since v0.1.0