Harnesses

A harness is the adapter that creates sessions, receives Relay messages, emits observations, and releases the session boundary.

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;
  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;
  /** Provide only when `capabilities.lifecycle.release` is `true`. */
  release?(reason?: string): Promise<void>;
};

Define a harness with defineHarness from @agent-relay/harnesses. The create function returns the session contract above — release is optional, so include it only when your capabilities declare lifecycle.release: true.

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. Declare a capability only when you implement it: include release here only because the capabilities below set lifecycle.release: true.

minimum-harness.ts
import { defineHarness } from '@agent-relay/harnesses';

const customHarness = defineHarness({
  name: 'task-bot',

  create: async (input, ctx) => {
    const identity = {
      id: ctx.ids.agent(input.name),
      name: input.name,
      handle: `@${input.name}`,
      type: 'agent' as const,
    };

    return {
      identity,
      capabilities: {
        messaging: { receive: true },
        delivery: { modes: ['immediate'] },
        events: { emits: ['status.changed'] },
        lifecycle: { release: true },
      },
      receiveMessage: async (message, delivery) => {
        await deliverToMyAgent(message, delivery);
        return { status: 'delivered', deliveryId: delivery.id };
      },
      // Provided because lifecycle.release is true above — omit it when release is false.
      release: async (reason) => {
        await stopOrDetach(reason);
      },
    };
  },
});

Most real harnesses will also implement 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: HarnessDriverEvents;
  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 provides prebuilt definitions for common environments such as Claude Code, Codex, Gemini, and OpenCode. create({ relay }) spawns the agent and self-registers it, returning the live agent client — no separate relay.workspace.register(...) call.

prebuilt.ts
import { claude, codex } from '@agent-relay/harnesses';

const planner = await claude.create({ relay, model: 'sonnet' });
const engineer = await codex.create({ relay, model: 'gpt-5.5' });

await planner.sendMessage({ to: '#reviews', text: `${engineer.handle} let's pair on the migration.` });

The returned client carries the same identity, messaging, and predicate surface as a register-ed agent, so planner.status.becomes('idle') and engineer.tools.called('bash') work directly with relay.addListener(...).

Humans

A human is just a harness with no managed runtime. createHuman self-registers and returns a live client, mirroring claude.create({ relay }).

human.ts
import { createHuman } from '@agent-relay/harnesses';

const will = await createHuman({ relay, name: 'will-washburn' });
await will.sendMessage({
  to: '#customer-complaints',
  text: `${planner.handle} please prioritize the most important work with ${engineer.handle}.`,
});

Continue to session capabilities and the full event list.