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.
CredentialPathsandDenyNamesare 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
LocalRootmay 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 skillfetches 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
- 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
niland move on. - Expect terminal noise. Headless runs still go through a TTY, so output
arrives with CRLF endings, interleaved escape sequences, and arbitrary chunk
boundaries.
LineNormalizerscrubs that before your adapter sees it, but do not assume a line is JSON: check the first byte before unmarshalling, the wayinternal/adapter/claude.godoes. - 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. - Summarize, do not transcribe.
Detailis 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.


