Prove what an API consumer observes, not that a handler returned or CI passed.
The consumers are agents and the programs that carry them: the SDK's
RemoteConversation/RemoteWorkspace, the TypeScript client, Agent Canvas and
automations. Three parts work together:
control-agent-server(scripts/) is the lever. It launches this checkout as an isolated real server (privateHOME, persistence dir, config file, session key, secret key, port), and turns every API step into a command you can rerun: REST calls, WebSocket captures, LLM-backed conversations, fixtures, SDK programs.- The feature map (references/feature-map/)
lists every agent-facing behavior with stable IDs, the routes each family
owns, entry points (REST, WebSocket, SDK and TypeScript client methods),
exact
control-agent-serverrecipes, observable results and gotchas. - Evidence: saved request/response exchanges, WebSocket frames, webhook deliveries and program transcripts, plus a pass/fail/blocked/not-run ledger that survives cleanup.
Neither this skill nor the map authorizes external writes, paid models beyond the budget you were given, or product fixes the user did not ask for.
The CLI comes first
Put it on PATH and read its help before anything else:
export PATH="$PWD/.agents/skills/verify-agent-server/scripts:$PATH"
control-agent-server --help # then: control-agent-server <command> --help
Requirements: make build (or uv sync --dev) in the repository root; the
CLI runs on the repository's .venv and needs nothing else. git is needed
for fixtures, tmux for bash and terminal tools, and a DeepSeek key for
model-backed recipes. Every command prints one JSON object; exit 0 ok,
1 action failed, 2 usage, 3 environment.
If an agent-facing path cannot be driven with the CLI, that is a harness
gap: extend scripts/control_agent_server.py (document the verb in its
--help with examples and in this file), prove it live, then write the recipe.
Never work around a gap with an untracked one-off script the next agent cannot
rerun. api and ws reach every route, so most gaps are about waiting,
fixtures or observing side effects, not about calling an endpoint. Other agents
may be running the same script: edit a copy, run its tests and
map check, then move it into place in one step.
Launch → doctor → drive → evidence → cleanup
export AGENT_SERVER_VERIFY_RUN=$(control-agent-server launch --new --print-run)
control-agent-server doctor # read-only; must be ok before driving
control-agent-server llm preset deepseek # deepseek-flash (active) + deepseek-pro from $DEEPSEEK_API_KEY
CID=$(control-agent-server conversation start --prompt 'Create hello.txt containing hi' --wait --print-id)
control-agent-server conversation events "$CID" --kinds ObservationEvent --contains hello.txt
control-agent-server api GET "/api/conversations/$CID" --expect 200 --save F03.get/after-run
control-agent-server evidence add --feature F03.get --result pass --entry "GET /api/conversations/{id}" \
--expected "execution_status finished" --actual "finished" --artifact evidence/F03.get/after-run.json
control-agent-server stop # stops only this run; evidence stays
- Which run. Commands use
--run DIR(anywhere on the line), else$AGENT_SERVER_VERIFY_RUN, else the only live run. With several live runs they refuse to guess, so exportAGENT_SERVER_VERIFY_RUNin every shell when other agents share the machine: an agent that drives someone else's run corrupts both evidence ledgers. Neverpkill -forkillallby pattern;control-agent-server stopends only your run's process groups. - Launch runs
python -m openhands.agent_serverfrom the checkout in its own process group, with cwd,HOME,OH_PERSISTENCE_DIR, the config file (OPENHANDS_AGENT_SERVER_CONFIG_PATH), tmux dir, worktree root, session key (OH_SESSION_API_KEYS_0) and secret key (OH_SECRET_KEY) all private to the run. The caller'sOH_*, LLM and cloud variables are not inherited; pass what a recipe needs with--env K=Vor--pass-env NAME. Useful variants:--no-auth(unauthenticated mode),--deferred-init(dormant untilPOST /api/init),--webhook-sink(a recording webhook receiver wired into the config),--config-json '{...}'(any other config field),--vscode,--preload-tools, and--checkout PATH(another worktree, for a baseline). Nested launches in a map recipe inherit the selected checkout; an explicit--checkoutoverrides it.attach --url ... --key-env VARdrives a server you did not launch (a Docker image, the PyInstaller binary, a remote sandbox);stopnever kills it. Launch refuses to start with less than about 1 GB free: a run with a model conversation holds 300 to 600 MB. - Doctor checks the process group, that this run owns its port,
/alive,/health,/ready,/server_info(version, and thatbuild_git_shais the checkout's HEAD), that a missing key is rejected (401) and the run's key accepted, that a rotated-out key is rejected, the OpenAPI document, WebSocket first-frame auth (bad key closes with 4001), and tracebacks in the log. Run it first, after every surprising failure, and before blaming the product. - Drive with
api METHOD PATH(any REST route,OPTIONSandHEADincluded;--auth none|bad|previous|bearer|bearer-bad|initfor auth cases,--expectfor the status,--check FIELD OP VALUEand--check-headerfor the body and headers,--fieldto chain values into shell variables,--jarfor cookies,--until-okto poll,--save FEATURE/NAMEfor evidence),ws listen|start|read|stop(any WebSocket;--auth first-frame|query|header|none|bad|bad-query|bad-header|previous|key,--expect-rejectfor close codes, replay with--query resend_mode=all, background captures that span other commands,--waitonread|stopto poll until the expectations hold),conversation start|wait|send|events(the essential agent pathways),sink read(what a webhook, telemetry or credential sink received),state ls|cat|grep(what the server persisted, and that secrets are not in it),configandcapabilities, andexec -- <program>(an SDK or TypeScript-client program withAGENT_SERVER_URLandSESSION_API_KEYinjected).logs --grepreads the server log with secrets redacted. - Arrange, don't fake.
llm,fixtureand setup calls throughapiexist to reach preconditions (a configured profile, a git repository, a skill, a stdio MCP server, an HTTP sink). They never count as proof of the feature that consumes them; the map says which steps are proof. Never patch the server or mock a server response to make a live check pass. The one stand-in isfixture llm-stub: an OpenAI-compatible provider on loopback that replays scripted steps (reply,tool,status,hang) through the server's real LLM client. Use it only for provider behavior a real model cannot produce on demand (a 401, a 500, a hang, an exact tool call such asfinishor a risky command); every happy path runs on a real model. A recipe-local process that later bullets use (an HTTP fixture server, a proxy) runs underhelper start --name NAME -- CMD, notCMD &:map runends each bullet's process group when the bullet ends. - State control reaches states a happy path never shows, without mocks:
restart(same state after a restart: persistence, restore, reconnection),restart --hard(SIGKILL: crash recovery),restart --rotate-key(stale session key),launch --share-conversations-from RUN(a second instance on the same conversations: leases and takeover),restart --env K=Vor--config-json(config changes such asmax_concurrent_runsor idle TTL),launch --deferred-init,launch --no-auth,conversation start --no-run, andws startbefore an action so its events are captured live. - Evidence goes under
<run>/evidence/<feature-id>/and the append-only ledger<run>/evidence/ledger.jsonl. Nothing is overwritten: a repeated name is saved as<name>-2.json, so cite the path the command prints;evidence reportrenders the report contract. The CLI redacts the run's keys,$DEEPSEEK_API_KEYand cipher-encrypted values from output and evidence, including--fieldand--print-header. Onlyapi --unsafe-unredactedwith either flag returns raw secret values for a round trip; capture those directly in variables and keep them out of reports. Evidence remains redacted. Keys, logs and state stay in<run>/private/. Evidence is not automatically public: read it before publishing it. - Cleanup with
control-agent-server stop(add--purge-privateto delete keys, state and fixtures once the evidence is checked). It signals only the process groups this run started (server, HTTP sinks, background captures, helpers) and verifies the port closed.
LLM budget
Use deepseek-flash (DeepSeek V4.1 Flash) for everything that needs a model;
switch to deepseek-pro only for checks that need a second profile or a
stronger model. Keep prompts tiny, deterministic and confined to the
conversation's workspace ("Create hello.txt containing exactly: hi"), and
assert on tool observations and files, never on the model's wording. Stop or
delete conversations a recipe no longer needs. Without a key, run every
credential-free recipe and record model-dependent ones as blocked with the
missing prerequisite; never substitute a mock and call it a pass.
Choose the job
- Verify a change or a PR: map the changed paths to feature IDs with
control-agent-server map owners --changed origin/main...HEAD, drive every entry point those features list (REST, WebSocket, SDK, TypeScript client), and report with the report contract. A behavior change needs a regression test as well (usually intests/cross/test_remote_conversation_live_server.py); the recipe is the live proof, not a substitute for that test. - Verify an existing fix (an open PR or a merged commit that claims to fix
a
known bugbullet): reproduce on the baseline twice and the fix twice, with the same flags and fixtures:control-agent-server map run --file Fnn --only Fnn.x --fresh --repeat 2 --checkout <base worktree>must report the bulletxfailin both attempts, and the same command without--checkout(the fix)xpassin both.xfailmeans the marked command failed with the failure its# bug: PREDICATEdocuments (see the marker contract); any other failure on the baseline is afail, so the proof cannot rest on an unrelated error. Then drop the marker in the same PR. Baseline not reproducing twice means "inconclusive", never "fixed".--onlyrejects unknown IDs and also runs each selected recipe's transitiveRequires:prerequisites in family order. - Create or extend the map: follow references/mapping.md.
control-agent-server map coveragemeasures which routes no family owns or drives. - Re-prove the map: every family's recipes are fenced
shblocks whose commands assert their own expectations, socontrol-agent-server map run --file Fnn --fresh --recordreplays a family on its own fresh run (with the family'sLaunch:flags) and writes ledger rows, andcontrol-agent-server map run --all --fresh --recordreplays the whole map. Only failing bullets need judgment;known bugbullets report as expected failures until the bug is fixed. - Run the daily pass: follow references/daily.md:
static checks (
map check,map coverage), the families changed since theMaintenance baselineline of the map index (map baseline;map ownerswith no arguments diffs from it), and nothing else: families nothing in the range touched wait for the weekly pass, and a pass with no new commits onmainstops early. A pass that finished its changed families proposesTARGETas the next baseline withmap baseline --set "$TARGET"in its PR; compare ledgers withevidence report --baselinewhen yesterday's is at hand. - Maintain the map (periodic or weekly): follow references/maintenance.md: a frozen BASE/TARGET, a route diff, a PR-intent ledger, a source wave, one live pass over every family, and at most one PR of proven corrections.
Triage what you find
- Map drift: the map describes something the server no longer does by design. Fix the entry with current source and live evidence.
- Harness gap: the server works but the CLI cannot drive or observe it. Fix the CLI, re-drive.
- Product bug: the server is broken. Keep the evidence, search existing
issues, and report it separately: Agent Server, SDK and the TypeScript client
here (
OpenHands/agent-sdk), Agent Canvas UI inOpenHands/OpenHands, scheduling and dispatch inOpenHands/automation. Never rewrite an expected result to bless broken behavior. - Blocked: name the missing prerequisite (key, account, Docker, binary, OS)
and the route you attempted. Unreachable is never a pass.
control-agent-server capabilitiessays what this machine can drive and which families'Needs:are unmet. A family'sNeeds:line gates the whole family under--all; a recipe's ownNeeds:line blocks only that recipe and the ones that require it, so the rest of the family still runs on a machine without it.
How this fits with the tests
Unit tests (tests/agent_server/), the in-process live-server tests
(tests/cross/test_remote_conversation_live_server.py), the stress suite, the
REST breakage check (oasdiff against the last release) and the TypeScript
client's endpoint audit prove contracts in isolation, with scripted LLMs. This
skill proves the assembled server as agents use it: one real process, real
persistence and restarts, real sockets and webhooks, and a real model. It does
not replace any of them.
See references/adaptation.md for where these ideas come from and what was deliberately left out.