Skip to content

Bridge

A bridge is the program through which hosts reach an agent that works in a sandbox, such as opencode, Codex, or Claude Code. It is all the sandbox exposes of the agent: it listens on a port of its own, behind a token that only hosts know, and runs the agent where nothing outside the sandbox reaches it.

A bridge is named by its BridgeId, which hosts derive from everything they launch it with, and serves every session that hosts start with those settings. A host finds the bridge by its id, in the listening.json the bridge writes in its state directory, and launches it when none answers there; a host with other settings runs a bridge of its own beside it. A bridge exits once it has been idle for a while, or once another bridge of its id has taken its place.

The bridge runs each turn itself, so that a turn goes on when a host’s connection drops or the host releases the session, until it finishes or a host aborts it. It numbers each turn’s parts and keeps them, so that a host reads them from any number: again after its connection drops, or to continue a turn that another host suspended. The agent behind it needs to do neither. A turn the bridge did not finish, as one it ran when it died, it continues when a host starts it again, by prompting the agent with Continue. in the conversation that holds the turn.

An agent’s adapter implements Driver, which drives the agent behind a bridge, and BridgeServer.serve serves the driver in the sandbox. The adapter’s Agent.Agent, which hosts run, reaches it with BridgeClient.createSession. Rpcs is the protocol between them, over a WebSocket whose host presents the token as Authorization: Bearer <token> when it connects.

Since v0.1.0

The name of the file in a bridge’s state directory that Listening describes.

Signature

declare const listeningFile: "listening.json"

Source

Since v0.1.0

The version of Rpcs that this package speaks.

Details

Every bridge’s id includes it, so a host only ever reaches a bridge of its own version. Bump it when hosts or bridges of the version before could not read a change to the protocol.

Signature

declare const protocolVersion: 2

Source

Since v0.1.0

What an adapter implements to run its agent behind a bridge: it drives the agent in the sandbox, starting its sessions and running their turns, while the bridge keeps the turns, so that a driver needs neither to suspend nor to continue one.

Details

BridgeServer.serve serves it.

Example (A driver over an agent’s own session API)

import { Bridge, StreamPart } from "@amends-dev/harness"
import { Effect, Stream } from "effect"
// What the adapter has of its agent in the sandbox: a client of the agent's own API.
declare const agent: {
readonly createSession: (directory: string) => Effect.Effect<string>
readonly prompt: (sessionId: string, text: string) => Stream.Stream<StreamPart.StreamPart>
readonly deleteSession: (sessionId: string) => Effect.Effect<void>
}
const driver: Bridge.Driver = {
id: "my-agent",
createSession: (options) =>
Effect.gen(function* () {
// A session the bridge starts again finds the agent's own session in `agentState`.
const kept = typeof options.agentState === "string" ? options.agentState : undefined
const sessionId = kept ?? (yield* agent.createSession(options.directory))
const session: Bridge.DriverSession = {
keptConversation: kept !== undefined,
promptTurn: ({ prompt }) => agent.prompt(sessionId, JSON.stringify(prompt.content)),
submitToolResult: () => Effect.void,
approve: () => Effect.void,
compact: () => Effect.die("my-agent compacts only by itself"),
state: Effect.succeed(sessionId),
destroy: agent.deleteSession(sessionId)
}
return session
})
}

Signature

export interface Driver {
/** Names the agent it drives, such as `opencode`, as the agent's `Agent.Agent` for hosts does. */
readonly id: string
/**
* Creates a session: a new one, or one created again from its `state`. The session runs until its
* scope closes, when a host stops or destroys it, or the bridge exits.
*/
readonly createSession: (
options: CreateSessionOptions
) => Effect.Effect<DriverSession, HarnessError.HarnessError, Scope.Scope>
}

Source

Since v0.1.0

A session a driver started. The bridge runs one turn of it at a time, keeps its turns’ parts, and ends it by closing its scope.

Signature

export interface DriverSession {
/**
* Whether the session started in the conversation the agent kept for it, which `agentState`
* names, rather than from `history`, as it does when it lost that conversation or had none. The
* bridge continues a turn it did not finish only in a conversation the agent kept.
*/
readonly keptConversation: boolean
/** Runs a turn, streaming its parts until `finish`. Interrupting the stream aborts the turn. */
readonly promptTurn: (options: TurnOptions) => Stream.Stream<StreamPart.StreamPart, HarnessError.HarnessError>
readonly submitToolResult: Agent.AgentSession["submitToolResult"]
readonly approve: Agent.AgentSession["approve"]
readonly compact: Agent.AgentSession["compact"]
/**
* What `createSession` takes as `agentState` to start the session again, as a restarted bridge
* does.
*/
readonly state: Effect.Effect<Schema.Json, HarnessError.HarnessError>
/** Deletes what the agent keeps of the session. Its scope closes afterwards. */
readonly destroy: Effect.Effect<void, HarnessError.HarnessError>
}

Source

Since v0.1.0

What a driver starts a session from: its id, its directory in the sandbox, the conversation so far, and the state the driver’s session last returned.

Signature

export interface CreateSessionOptions {
readonly id: Agent.SessionId
/** The absolute directory the session works in. It exists before `createSession`. */
readonly directory: string
/**
* The conversation so far, empty for a new session. An agent given `agentState` has it already;
* any other loads it before its first turn.
*/
readonly history: Prompt.Prompt
/** What the session's `state` returned when it last ran on this agent. */
readonly agentState?: Schema.Json | undefined
}

Source

Since v0.1.0

Answers which agent the bridge runs, and which bridge it is, for a host to check that it reached the bridge it looked up: a port another bridge listened on may be another’s since.

Signature

declare const Hello: Rpc.Rpc<
"Hello",
Schema.Void,
Schema.Struct<{ readonly agent: Schema.String; readonly bridge: Schema.brand<Schema.String, "BridgeId"> }>,
Schema.Never,
never,
never
>

Source

Since v0.1.0

The protocol between a host and a bridge: the requests a host sends over its WebSocket, which BridgeServer handles and BridgeClient sends.

Signature

declare class Rpcs

Source

Since v0.1.0

Names a bridge after everything a host launches it with, as Sandbox.isName checks, so that a host that would launch the same bridge finds the one running, and a host with other settings launches its own.

Signature

declare const BridgeId: Schema.brand<Schema.String, "BridgeId">

Source

Since v0.1.0

Signature

type BridgeId = typeof BridgeId.Type

Source

Since v0.1.0

What a host gives the bridge it launches, on the bridge’s standard input, so that neither the agent nor anything else in the sandbox can read the token or the agent’s credentials.

Signature

declare const LaunchOptions: Schema.Struct<{
readonly token: Schema.String
readonly bridge: Schema.brand<Schema.String, "BridgeId">
readonly stateDirectory: Schema.String
readonly agentOptions: Schema.Codec<Schema.Json, Schema.Json, never, never>
}>

Source

Since v0.1.0

Signature

export interface LaunchOptions extends Schema.Schema.Type<typeof LaunchOptions> {}

Source

Since v0.1.0

What a bridge writes to listeningFile in its state directory once it listens, for hosts to find it: the port. It holds no token, as the agent can read every file in the sandbox.

Signature

declare const Listening: Schema.Struct<{ readonly port: Schema.brand<Schema.Int, "Port"> }>

Source

Since v0.1.0

Signature

export interface Listening extends Schema.Schema.Type<typeof Listening> {}

Source

Since v0.1.0

A part of a turn, numbered from 1 in the order the turn streamed it.

Signature

declare const NumberedPart: Schema.Struct<{
readonly seq: Schema.Int
readonly part: Schema.Union<
readonly [
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"text-start">
readonly id: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"text-delta">
readonly id: Schema.String
readonly delta: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"text-end">
readonly id: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"reasoning-start">
readonly id: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"reasoning-delta">
readonly id: Schema.String
readonly delta: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"reasoning-end">
readonly id: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"tool-params-start">
readonly id: Schema.String
readonly name: Schema.String
readonly providerExecuted: Schema.withDecodingDefaultKey<Schema.Boolean>
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"tool-params-delta">
readonly id: Schema.String
readonly delta: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"tool-params-end">
readonly id: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.Struct<{
readonly type: Schema.tag<"tool-call">
readonly id: Schema.String
readonly name: Schema.String
readonly params: Schema.Unknown
readonly providerExecuted: Schema.optional<Schema.Boolean>
readonly metadata: Schema.optional<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json, Schema.Json, never, never>>>
>
}>,
Schema.Struct<{
readonly type: Schema.tag<"tool-result">
readonly id: Schema.String
readonly name: Schema.String
readonly result: Schema.Unknown
readonly isFailure: Schema.Boolean
readonly providerExecuted: Schema.optional<Schema.Boolean>
readonly preliminary: Schema.optional<Schema.Boolean>
readonly metadata: Schema.optional<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json, Schema.Json, never, never>>>
>
}>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"tool-approval-request">
readonly approvalId: Schema.String
readonly toolCallId: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"file">
readonly mediaType: Schema.String
readonly data: Schema.Uint8ArrayFromBase64
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"source">
readonly sourceType: Schema.tag<"document">
readonly id: Schema.String
readonly mediaType: Schema.String
readonly title: Schema.String
readonly fileName: Schema.optionalKey<Schema.String>
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"source">
readonly sourceType: Schema.tag<"url">
readonly id: Schema.String
readonly url: Schema.URLFromString
readonly title: Schema.String
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"response-metadata">
readonly id: Schema.optional<Schema.String>
readonly modelId: Schema.optional<Schema.String>
readonly timestamp: Schema.optional<Schema.DateTimeUtcFromString>
readonly request: Schema.optional<typeof HttpRequestDetails>
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"finish">
readonly reason: Schema.Literals<
["stop", "length", "content-filter", "tool-calls", "error", "pause", "other", "unknown"]
>
readonly usage: typeof Usage
readonly response: Schema.optional<typeof HttpResponseDetails>
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.toEncoded<
Schema.Struct<{
readonly type: Schema.tag<"error">
readonly error: Schema.Unknown
readonly "~effect/ai/Response/Part": Schema.withDecodingDefaultKey<Schema.tag<"~effect/ai/Response/Part">>
readonly metadata: Schema.withDecodingDefault<Schema.$Record<Schema.String, Schema.Codec<Schema.Json>>>
}>
>,
Schema.Struct<{
readonly type: Schema.tag<"finish-step">
readonly reason: Schema.Literals<
["stop", "length", "content-filter", "tool-calls", "error", "pause", "other", "unknown"]
>
readonly usage: Schema.toEncoded<typeof Usage>
readonly metadata: Schema.optional<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json, Schema.Json, never, never>>>
>
}>,
Schema.Struct<{
readonly type: Schema.tag<"file-change">
readonly event: Schema.Literals<readonly ["create", "modify", "delete"]>
readonly path: Schema.String
readonly metadata: Schema.optional<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json, Schema.Json, never, never>>>
>
}>,
Schema.Struct<{
readonly type: Schema.tag<"compaction">
readonly trigger: Schema.Literals<readonly ["manual", "auto"]>
readonly summary: Schema.String
readonly kept: Schema.optional<
Schema.$Array<Schema.toEncoded<Schema.Codec<Prompt.Message, Prompt.MessageEncoded, never, never>>>
>
readonly tokensBefore: Schema.optional<Schema.Int>
readonly tokensAfter: Schema.optional<Schema.Int>
readonly metadata: Schema.optional<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json, Schema.Json, never, never>>>
>
}>,
Schema.Struct<{
readonly type: Schema.tag<"turn-start">
readonly agentState: Schema.Codec<Schema.Json, Schema.Json, never, never>
}>
]
>
}>

Source

Since v0.1.0

Signature

export interface NumberedPart extends Schema.Schema.Type<typeof NumberedPart> {}

Source

Since v0.1.0

How a turn answers: in text, or in JSON that matches schema, a JSON Schema.

Signature

declare const ResponseFormat: Schema.Union<
readonly [
Schema.Struct<{ readonly type: Schema.tag<"text"> }>,
Schema.Struct<{
readonly type: Schema.tag<"json">
readonly objectName: Schema.String
readonly schema: Schema.Codec<Schema.Json, Schema.Json, never, never>
}>
]
>

Source

Since v0.1.0

Signature

type ResponseFormat = typeof ResponseFormat.Type

Source

Since v0.1.0

A tool the host runs, as the agent reads it: its name, what it does, and its parameters.

Signature

declare const ToolDefinition: Schema.Struct<{
readonly name: Schema.String
readonly description: Schema.optional<Schema.String>
readonly parameters: Schema.Codec<Schema.Json, Schema.Json, never, never>
}>

Source

Since v0.1.0

Signature

export interface ToolDefinition extends Schema.Schema.Type<typeof ToolDefinition> {}

Source

Since v0.1.0

Names a turn, as Sandbox.isName checks. The host picks it, so that a request to start the turn that it sends again, not knowing whether the first arrived, starts it once.

Signature

declare const TurnId: Schema.brand<Schema.String, "TurnId">

Source

Since v0.1.0

Signature

type TurnId = typeof TurnId.Type

Source

Since v0.1.0

A turn’s settings, as Agent.TurnOptions gives them, with the host’s tools and the answer’s schema as JSON Schemas, since only data crosses into the sandbox.

Signature

declare const TurnOptions: Schema.Struct<{
readonly prompt: Schema.Struct<{
readonly role: Schema.Literal<"user">
readonly content: Schema.Union<
readonly [
Schema.decodeTo<
Schema.NonEmptyArray<
Schema.toType<
Schema.Struct<{
readonly type: Schema.Literal<"text">
readonly text: Schema.String
readonly "~effect/ai/Prompt/Part": Schema.withDecodingDefaultKey<
Schema.Literal<"~effect/ai/Prompt/Part">
>
readonly options: Schema.withDecodingDefault<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json>>>
>
}>
>
>,
Schema.String,
never,
never
>,
Schema.$Array<
Schema.Union<
readonly [
Schema.Struct<{
readonly type: Schema.Literal<"text">
readonly text: Schema.String
readonly "~effect/ai/Prompt/Part": Schema.withDecodingDefaultKey<
Schema.Literal<"~effect/ai/Prompt/Part">
>
readonly options: Schema.withDecodingDefault<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json>>>
>
}>,
Schema.Struct<{
readonly type: Schema.Literal<"file">
readonly mediaType: Schema.String
readonly fileName: Schema.optional<Schema.String>
readonly data: Schema.Union<readonly [Schema.String, Schema.Uint8Array, Schema.URL]>
readonly "~effect/ai/Prompt/Part": Schema.withDecodingDefaultKey<
Schema.Literal<"~effect/ai/Prompt/Part">
>
readonly options: Schema.withDecodingDefault<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json>>>
>
}>
]
>
>
]
>
readonly "~effect/ai/Prompt/Message": Schema.withDecodingDefaultKey<Schema.Literal<"~effect/ai/Prompt/Message">>
readonly options: Schema.withDecodingDefault<
Schema.$Record<Schema.String, Schema.NullOr<Schema.Codec<Schema.Json>>>
>
}>
readonly instructions: Schema.optional<Schema.String>
readonly model: Schema.optional<Schema.String>
readonly tools: Schema.$Array<
Schema.Struct<{
readonly name: Schema.String
readonly description: Schema.optional<Schema.String>
readonly parameters: Schema.Codec<Schema.Json, Schema.Json, never, never>
}>
>
readonly responseFormat: Schema.Union<
readonly [
Schema.Struct<{ readonly type: Schema.tag<"text"> }>,
Schema.Struct<{
readonly type: Schema.tag<"json">
readonly objectName: Schema.String
readonly schema: Schema.Codec<Schema.Json, Schema.Json, never, never>
}>
]
>
}>

Source

Since v0.1.0

Signature

export interface TurnOptions extends Schema.Schema.Type<typeof TurnOptions> {}

Source

Since v0.1.0