A harness is how a concrete agent environment gets on Relay.
Claude Code in a terminal, Codex in a managed session, OpenCode as an app server, OpenClaw through an adapter, a browser app, and your own hosted worker can all be harnesses. Relay does not need to own the process. It needs the harness to create a session that can receive messages, emit events, and be released.
The Line Between Harness And Session
The harness is the factory and adapter definition. The session is the created thing Relay can address.
type HarnessConfig<TInput = void> = {
name: string;
init?(ctx: HarnessInitContext): Promise<void> | void;
create(input: TInput, ctx: HarnessCreateContext): Promise<AgentSession>;
};
type AgentSession = {
identity: AgentIdentity;
capabilities: AgentSessionCapabilities;
receiveMessage(message: RelayMessage, ctx: MessageContext): Promise<MessageReceipt>;
onEvent?(handler: (event: AgentSessionEvent) => void): Unsubscribe;
release(reason?: string): Promise<void>;
};The harness owns provider-specific setup: binaries, SDK clients, app-server URLs, environment variables, credentials, terminal emulators, hooks, headless modes, and connection pools.
The session owns per-agent state: identity, capabilities, delivery, observation events, and release.
Why create Is General
create does not have to mean spawn.
For different harnesses it may mean:
- start a CLI process
- attach to an existing terminal
- connect to an app-server session
- resume a previous conversation
- allocate a hosted worker
- register a browser tab or UI agent
- return a handle to something that already exists
Relay should not force a public distinction between own, attach, wrap, and spawn. Those are implementation details inside the harness.
Minimum Harness
The minimum useful harness creates a session that can receive messages and be released.
const customHarness: HarnessConfig<{ name: string }> = {
name: 'custom',
async create(input, ctx) {
return {
identity: {
id: ctx.ids.agent(input.name),
name: input.name,
handle: `@${input.name}`,
kind: 'agent',
},
capabilities: {
messaging: { receive: true, send: true, attachments: ['text'] },
delivery: { modes: ['immediate'] },
events: { emits: ['status.changed', 'message.received'] },
actions: { invoke: true },
lifecycle: { release: true },
},
async receiveMessage(message, delivery) {
await deliverToMyAgent(message, delivery);
return { status: 'delivered', deliveryId: delivery.id };
},
async release(reason) {
await stopOrDetach(reason);
},
};
},
};Most real harnesses will also implement init, onEvent, richer delivery modes, and action support.
Harness Context
type HarnessInitContext = {
workspace: Workspace;
logger: RelayLogger;
env: Record<string, string | undefined>;
};
type HarnessCreateContext = HarnessInitContext & {
ids: {
agent(name: string): string;
session(name: string): string;
};
messaging: RelayMessaging;
actions: AgentRelayActions;
events: AgentRelayEvents;
signal?: AbortSignal;
};The context gives the harness workspace information and access to Relay services without requiring the harness to import app-specific globals.
Session Identity
Identity is stable routing information. It is not capability state and it is not lifecycle state.
type AgentIdentity = {
id: string;
name: string;
handle: string;
kind?: 'agent' | 'human' | 'system' | 'service';
metadata?: Record<string, unknown>;
};Status belongs in events. Capabilities belong on the session. A session may emit status.changed events over time while keeping the same identity.
Receive Messages
receiveMessage is the core delivery method.
async receiveMessage(message: RelayMessage, ctx: MessageContext): Promise<MessageReceipt> {
if (!this.supports(ctx.mode)) {
return {
status: 'deferred',
deliveryId: ctx.id,
availableAt: new Date(Date.now() + 30_000),
reason: `Mode ${ctx.mode} is not available yet.`,
};
}
await this.inject(message, ctx);
return { status: 'delivered', deliveryId: ctx.id };
}The harness can implement delivery with whatever mechanism is correct for its agent environment. Relay only needs the receipt.
Emit Events
onEvent connects harness observations to Relay listeners.
const unsubscribe = session.onEvent?.((event) => {
relay.events.emitSessionEvent(session.identity, event);
});Harnesses should normalize provider-specific observations into stable session events. They should redact secrets before emitting terminal output, transcript chunks, tool inputs, or file diffs.
Actions From Sessions
A session can support action invocation without knowing every action name in advance.
capabilities: {
actions: { invoke: true },
}This means the session can call SDK actions through the Relay action protocol, usually through MCP tools. Individual action availability is governed by the action registry and policy hooks, not by enumerating every action in session capabilities.
A session can also expose actions when it is itself the implementation boundary.
capabilities: {
actions: { invoke: true, expose: true },
}For example, a UI harness might expose ui.show_search_results, while a managed runtime harness might expose agent.create.
Release
release reverses what create did.
await session.release('review completed');Depending on the harness, release may:
- kill a process
- detach from an existing process
- close a WebSocket
- archive a session
- mark a hosted agent unavailable
- remove a participant from active delivery
Relay should call the session method and record the lifecycle event. The harness owns the implementation.
Prebuilt Harnesses
@agent-relay/harnesses can provide prebuilt definitions for common environments such as Claude Code, Codex, OpenCode, and OpenClaw.
import { claude, codex, openclaw } from '@agent-relay/harnesses';
const planner = await claude.create({ name: 'planner', model: 'sonnet' });
const engineer = await codex.create({ name: 'engineer', model: 'gpt-5.5' });
const claw = await openclaw.create({ name: 'claw-reviewer' });
await relay.workspace.register([planner, engineer, claw]);The prebuilt harnesses should feel consistent at the Agent Relay layer even when their underlying provider mechanics are different.