v0.5.1-alpha.2 is outsee the release →
All guides
[ DOCS ]

Adding a harness or an adapter

docs/adapters.md on GitHub →

Two independent seams. A harness profile teaches Aether how to launch an agent CLI - that is all most agents need. An adapter additionally turns the agent's machine-readable output into typed events. Adapters are optional by design: a run without one still has a full PTY transcript and a git-diff timeline, and no feature is allowed to hard-require adapter events.

Read harnesses.md first for what already ships.


Part 1: the harness profile

Everything lives in internal/harness/harness.go. Adding an agent is adding one entry to the profiles map:

Field What to put there
Name The value users pass to --agent. Match the map key.
TUIArgs Argv for the interactive TUI. Use TaskPlaceholder ({task}) where the prompt goes.
HeadlessArgs Argv for the machine-readable mode. Same placeholder.
EnvPassthrough Environment variables copied from the server process into run containers when set. API keys only.
Env Fixed environment variables the CLI needs to start at all, applied after the workspace's own so a workspace cannot break the launch. Not for configuration - a variable belongs here only when the agent refuses to run without it.
CredentialPaths Home-relative directories holding native login state. Persisted with the member account and mounted read-write into every run using that account. Directories, not files.
LocalRoot Home-relative configuration root exposed to the browser's one-time import and the Files editor. It also names the local root used by the explicit profile CLI commands. Empty means the harness has no configuration root.
DenyNames Basenames the browser import skips before upload and the manual profile path excludes - credential files, token caches, keychains.
User An explicit numeric uid:gid for images whose configured user is a name. Usually leave empty.
DiscoveryArgs, DiscoveryEnv, DiscoveryFiles Vendor-native, per-launch startup guidance for taskless TUI runs. Files are staged read-only in /run/aether; nothing is written to the member home or repository.

Rules that are easy to get wrong:

  • Apply the agent's full-permission flag in both modes. Auto-permission is the default stance: the container is the isolation boundary, and an agent stopping to ask for approval in a headless fleet is a hang, not a safeguard.

  • CredentialPaths and DenyNames are two different lists. The first says what to persist across runs; the second says what configuration import and explicit profile commands must never upload. A credential file usually appears in both, from opposite directions.

  • Nothing under LocalRoot may be assumed private. Browser import sends ordinary remaining bytes to the server for scanning. Known credential names are skipped locally, but scanner findings are a server-side boundary, not a promise that all secret content stays local.

  • Configuration has two explicit paths. The browser directory picker imports once into the authenticated member's persistent home; the Files editor then reads and writes that home. A local daemon never watches LocalRoot.

  • Taskless discovery is runtime-scoped. Use the vendor's documented startup instruction mechanism and keep the hint short; aether-internal skill fetches the assignment-specific details from the run socket.

  • An argv override must stay verbatim. The scheduler drops the shipped taskless discovery and status wiring when a definition replaces a shipped harness, because nothing checks that the override is still that CLI.

Then add coverage in internal/harness/harness_test.go alongside the existing table-driven cases, and a row in the tables in harnesses.md - a change that makes a doc wrong is not finished until the doc is fixed.

That is the whole harness change. The scheduler resolves argv, mounts, run user and per-launch discovery/status assets from the profile; nothing else needs editing.


Part 2: the adapter

Only worth writing when the agent has a structured output mode - a newline-delimited machine-readable stream, like Claude Code's --output-format stream-json. Adapters only ever see headless runs.

The interface, from internal/adapter/adapter.go, is one method:

type Adapter interface {
    ConsumeLine(line string) []events.Payload
}

Register a constructor in the adapters map keyed by harness name, and the Manager does the rest: it watches the bus for headless runs entering running, taps the run's PTY output, normalizes it into lines, feeds them to your adapter, and publishes whatever payloads come back under the run's workspace and run IDs.

The four rules

  1. Never return an error. There is no error in the signature and that is deliberate. A line that does not parse is not a failure - it is ordinary PTY output that happened to flow past. Return nil and move on.
  2. Expect terminal noise. Headless runs still go through a TTY, so output arrives with CRLF endings, interleaved escape sequences, and arbitrary chunk boundaries. LineNormalizer scrubs that before your adapter sees it, but do not assume a line is JSON: check the first byte before unmarshalling, the way internal/adapter/claude.go does.
  3. Stay stateless if you can. A fresh adapter is constructed per run. If you must correlate records - a tool result to its call - carry the harness's own IDs on the payload (ToolUseID) instead of keeping a map.
  4. Summarize, do not transcribe. Detail is a short human-readable line for the timeline, truncated by the adapter (the Claude adapter caps it at 256 bytes). The full output already lives in the PTY transcript.

What to emit

Most adapter output is events.AgentEventPayload:

Kind Meaning Fields that matter
AgentToolCall The agent invoked a tool Tool, ToolUseID, Detail
AgentToolResult A tool invocation finished ToolUseID, IsError
AgentSubagent The agent spawned a subagent Tool, ToolUseID, Detail
AgentPause The agent is waiting on plan review or approval Detail
AgentSession The harness's own session ID, surfaced on the timeline HarnessSessionID

Token usage is not an agent event: report it as events.RunCostPayload so it reaches the cost rollups and workspace budgets. A harness that reports no usage leaves its runs marked unmetered, which aether cost and aether budget both say out loud.

AgentSession carries the harness's own conversation ID, which is unrelated to any Aether scope. It is a timeline record only: Aether does not assign a conversation ID from it or use it to control relaunch. Emit it anyway - it is what an operator needs to find the conversation in the harness's own tooling.

Testing

Record a real stream and replay it. internal/adapter/testdata/ holds two fixtures per harness-shaped case, and both matter:

  • claude_clean.jsonl - the raw stream as the harness documents it.
  • claude_tty.jsonl - the same stream after a TTY has had its way with it.

Assert on the payloads your adapter produces from each. A fixture-driven test is the whole test: no live agent, no network, no mocking of the bus.


Where the seam ends

The registry is a map and adapters are one file each, on purpose. If a change needs a plugin loader, a config file, or a new interface, it is probably not an adapter change. Keep broader changes aligned with the public architecture and protocol documentation before building them.