Use it from your agent — DeepSeek Harness, Claude Code, Codex, OpenCode¶
Everything else in these docs is Python: you write a dozen lines and hand them
to evolve(). This page is for the other audience — a person sitting inside
an agent (DeepSeek Harness, Claude Code, Codex, OpenCode) who wants to say "evolve this
skill against these examples" and have the agent do it. It is also for the
agent: the same surface is what the agent calls.
The design record — why it is shaped this way and what was considered — is Plugin for DSH, Claude Code, Codex and other agents.
Install¶
pip install "agentdescent[mcp]" # the CLI works without [mcp]; the MCP server needs it (and Python >= 3.10)
agentdescent demo # a complete evolution, offline, no key
agentdescent doctor # which agent CLIs, keys and optional pieces are here
agentdescent install dsh # or: claude-code, codex, opencode
The MCP SDK requires Python >= 3.10; AgentDescent supports 3.9. On 3.9 the
extra installs nothing and the tool surface is unavailable — the CLI verbs are
the whole API, which is what the shipped SKILL.md falls back to. doctor and
install both say so rather than letting it surface as a host reporting
CONNECTION_CLOSED.
If you have never run one, do demo before install: it builds a skill that is
wrong on purpose, twelve cases with known answers and a local program as the
agent, then runs the real loop — same staging, ledger, workers, merge and
held-out gate, only the model replaced. Start here — the plugin in three
commands walks the whole path.
install writes the shared skill and the host's manifest, and nothing else:
| host | what it writes | how to load it |
|---|---|---|
dsh |
~/.dsh/skills/agentdescent/SKILL.md + hooks.json; an @deepseek-ai/dsh-mcp-client entry and the hooks-claude-code bridge appended to ~/.dsh/cordis.patch.yml |
restart dsh; check with dsh --profile web --dump-config \| grep agentdescent |
claude-code |
a plugin directory at ~/.agentdescent/plugins/claude-code/ (plugin.json, skill, /agentdescent:evolve, .mcp.json, a SessionStart hook) |
claude --plugin-dir ~/.agentdescent/plugins/claude-code, or /plugin marketplace add Birfy/agentdescent then /plugin install agentdescent@agentdescent |
codex |
~/.codex/skills/agentdescent/SKILL.md; an [mcp_servers.agentdescent] block appended to ~/.codex/config.toml |
restart Codex; check with codex mcp list. Or skip install entirely and use the plugin — see below |
opencode |
~/.config/opencode/skill/agentdescent/SKILL.md; an mcp.agentdescent entry merged into ~/.config/opencode/opencode.jsonc |
restart OpenCode; check with opencode mcp list |
Codex: the same plugin as Claude Code¶
Codex reads the Claude Code plugin format, marketplace and all, so the package this repository already publishes installs there in one command — and brings the MCP server with it, so none of the file edits above are needed:
codex plugin marketplace add Birfy/agentdescent # or a local checkout path
codex plugin add agentdescent@agentdescent
codex plugin list # installed, enabled
codex mcp list # agentdescent ... enabled
Verified against codex-cli 0.153.4: codex plugin list reads
.claude-plugin/marketplace.json and resolves the plugin at
integrations/claude-code, and the server it declares shows up in
codex mcp list with nothing written into mcp_servers by hand.
Why OpenCode is configuration and not a plugin¶
OpenCode has a plugin system too, but it is a different thing: a plugin is a JS
module (@opencode-ai/plugin) whose Hooks are interception points —
tool.execute.before / .after, chat.message, chat.params,
permission.ask, event, auth, and a config hook that can mutate the
loaded config.
It could therefore inject the MCP server programmatically. It is not worth it
for what AgentDescent contributes: a skill and an MCP server are both plain
configuration in OpenCode, so the plugin would add an npm package, a publish
step and a version to keep in step, to arrive at the same two entries
install opencode writes directly. A plugin would earn its place only for
behaviour configuration cannot express — intercepting tool calls, or pushing
run status into the chat — which is not what this integration does.
DeepSeek Harness: a native plugin, or the files¶
install dsh writes files and needs no npm. The alternative is the Cordis
plugin in integrations/dsh-agentdescent:
dsh plugin --profile web add link:/path/to/dsh-agentdescent
dsh --profile web --dump-config | grep -n agentdescent # both rows compose
It registers the skill through ctx.skills.register() at runtime -- no file to
install, and it cannot drift from the package -- and its own cordis.patch.yml
adds the MCP row. Use one route or the other, not both.
The plugin also contributes a runs panel to the dsh web UI: an evolve
action in the session header showing how many runs are live, and the list
behind a click. It reads agentdescent serve, a read-only loopback view of the
run store:
The panel is a plain browser page as well, so it is useful without dsh. The
server answers a cross-origin read only for a loopback Origin, so the dsh
page on :3080 can read it and a website you happen to visit cannot; without
the server running the panel just says how to start it.
Two things a dsh plugin needs that are easy to miss, both found by installing it
for real: package.json must carry "dsh": {"bundle": {"patch":
"./cordis.patch.yml"}} or dsh plugin add warns "declares no dsh.bundle ...
not a profile layer" and the plugin is inert; and rows go under - insert:,
because a patch file otherwise overrides rows by id.
It is idempotent (--dry-run shows what it would do) and honours DSH_HOME,
CODEX_HOME and XDG_CONFIG_HOME. Every manifest here was verified by writing
it and having the host read it back (dsh 0.1.2-rc.1, Claude Code 2.1.261,
codex-cli 0.153.4, opencode 1.18.29).
DeepSeek Harness scrubs provider keys
Before starting an MCP server, dsh removes every ambient variable matching
KEY|PASSWORD|SECRET|TOKEN. The patch entry install dsh writes forwards
DEEPSEEK_API_KEY, OPENAI_API_KEY, OPENAI_BASE_URL and
ANTHROPIC_API_KEY explicitly (!!js process.env.X). Without that block
the server starts with no credentials and doctor reports every reflector
as unavailable, with nothing else to explain why.
What the agent does¶
The skill file teaches the host model one procedure, and the tool surface enforces its shape:
planis separate fromstart.planresolves every reference, loads the data and builds the policy bundle without running anything, then returns the composedevolve()arguments and an upper bound on agent calls. A wrong field fails here, by name, not in round one of a background process.startreturns in under a second. The run is a detached process; the host pollsstatus. MCP tool calls time out and hosts restart their servers between sessions, so nothing blocks onevolve().applyis its own tool and says it is destructive. The evolved artifact reaches your real directory only through it, aftershowhas displayed the diff and the plan (write_to(dry_run=True)), and it backs up first.
The tool names differ by how the server was added, which matters when you allowlist them or write a probe that greps for one (verified against Claude Code 2.1 and the dsh mcp-client docs):
| added as | name |
|---|---|
a Claude Code plugin (what install claude-code writes) |
mcp__plugin_agentdescent_agentdescent__doctor — mcp__plugin_<plugin>_<server>__<tool> |
a plain MCP server entry in your own .mcp.json |
mcp__agentdescent__doctor |
a dsh mcp-client entry (what install dsh writes) |
mcp__agentdescent__doctor — mcp__<serverName>__<tool> |
| a Codex or OpenCode server entry | agentdescent's tools under that server's name |
In an interactive session you approve the tools when they are first called. In
headless use (claude -p) nothing prompts, so pass the names explicitly:
claude -p --plugin-dir <dir> --permission-mode acceptEdits \
--allowedTools mcp__plugin_agentdescent_agentdescent__doctor \
mcp__plugin_agentdescent_agentdescent__status
--allowedTools is variadic, so give the prompt on stdin (or put it before the
flag) or it will be swallowed as another tool name.
The spec¶
What the agent writes from your words, and what you can write yourself. It is
an evolve() call as data: every field is an ordinary argument or a public
building block, and agentdescent.evolvespec.compose is the only place the two
are joined.
{
"kind": "skill_dir",
"target": "~/.claude/skills/pdf-audit",
"data": {"path": "eval/cases.jsonl", "prompt": "question", "gold": "answer"},
"score": "contains",
"agent": {"ref": "claude_code", "extra_args": ["--permission-mode", "acceptEdits"]},
"reflect": {"ref": "openai_compatible", "model": "deepseek-v4-flash"},
"evolve": {"rounds": 6, "n_workers": 4, "asynchronous": true, "max_seconds": 3600}
}
kind picks a row of the composition table, which mirrors the quickstarts
line for line:
kind |
strategy | run |
propose |
reward | layer | defaults added |
|---|---|---|---|---|---|---|
text |
SingleSlot(target) |
model(template) |
reflector |
scorer |
L2 | rounds 8, patience 3, target 0.98 |
skill_dir |
FileTree(load_tree(target)) |
tree_runner |
tree_reflector |
scorer |
L2 | rounds 6, self_verify=False, cheap_eval_tasks=4 |
agent_dir |
same | tree_runner(layout="claude_agent") |
same | same | L1 | same |
agent_code |
same, tests frozen | code_runner(entrypoint, test_cmd) |
same | gated_reward(scorer) |
L1 | same |
plugin |
same, hooks frozen | plugin_runner(host) |
same | gated_reward(scorer) |
L1 | same, container advised |
The other fields:
data: one ofpath(.json/.jsonl/.csv),hf({"dataset", "split", "config", "limit"}, through the data layer) orinline(a list of rows);promptandgoldname the columns. Afixturescolumn is staged into the workspace.score: a name fromSCORERS(contains,exact,last_number,numeric_close);{"cmd": "./grade.sh"}to grade with any program (the task as JSON on stdin, the answer in$ANSWER, a number in[0, 1]on stdout — a linter, a compiler, a golden-file diff); or{"ref": "pkg.mod:fn"}.agent/reflect: a short name (claude_code,codex,dsh,opencode,openai_compatible,claude,host_model,echo), ormodule:attributeinside the import allowlist, with keyword arguments beside it."call": falsenames a callable rather than a factory. A cheapreflectbehind an expensiveagentis the usual trade.
Which model a run uses¶
A worker agent is the host CLI as a subprocess (claude -p …, codex exec …,
dsh --profile headless …, opencode run …), so the question "does it use the
model my agent is configured with" has a precise answer: not by default, and
on purpose.
agents.worker_env() inherits the environment — so provider keys in
ANTHROPIC_API_KEY / OPENAI_API_KEY / DEEPSEEK_API_KEY reach the worker —
but points each host's config directory inside the rollout workspace
(CLAUDE_CONFIG_DIR, CODEX_HOME, DSH_HOME, OPENCODE_CONFIG_DIR). The
worker therefore starts clean: it cannot read your plugins, your memory, your
MCP servers — or your model choice and your subscription login, which live in
those directories. That isolation is what makes a rollout reproducible, and it
is what keeps a plugin evolving itself from reading the copy being rewritten.
Two switches, both ordinary spec fields:
"agent": {"ref": "claude_code", "extra_args": ["--model", "haiku"]},
"reflect": {"ref": "claude_code", "extra_args": ["--model", "opus"]}
extra_args pins a model without giving up isolation — the flag is the
host's own: claude --model, codex exec -m/--model, opencode run -m/--model
(as provider/model). dsh has no --model: its model comes from the
profile, so select it with --patch or a prepared DSH_HOME.
isolate: false hands the worker your real setup — your configured model,
your subscription login, your plugins. This is the literal answer to "use the
model my main agent is configured with", and the reason it is not the default:
the worker now sees state you can change between rounds, and a kind: plugin
run would load the very plugin it is rewriting.
Either switch works for reflect as well as agent, and pointing both at a
host CLI is the way to run with no provider key at all — every call goes
through the CLI's own authentication:
{
"kind": "skill_dir",
"target": "~/.claude/skills/pdf-audit",
"data": {"path": "eval/cases.jsonl", "prompt": "question", "gold": "answer"},
"score": "contains",
"agent": {"ref": "claude_code", "extra_args": ["--permission-mode", "acceptEdits"], "isolate": false},
"reflect": {"ref": "claude_code", "isolate": false}
}
Borrowing the host's model: host_model¶
The third route needs no model name and no key at all:
host_model means the model of the agent that started this run, and it takes
whichever of two routes the host allows.
Route 1 — MCP sampling. sampling/createMessage lets a server ask its
client for a completion, so the model is the one the session is running, with
the session's authentication and policy. This is the better route and it is
currently the rarer one. Measured, by logging what each host sends at
initialize:
| host | clientInfo.name |
capabilities it declares | sampling? |
|---|---|---|---|
| Claude Code 2.1.261 | claude-code |
roots, elicitation |
no |
| OpenCode 1.18.29 | opencode |
roots |
no |
DeepSeek Harness (dsh-mcp-client 0.0.1) |
dsh-mcp-client |
none | no |
| Codex | — | — | not measured: codex mcp list never opens a session |
Route 2 — the host's own CLI. Since no host measured supports sampling,
host_model falls back to running that host's CLI (claude, codex, dsh,
opencode) with the user's real configuration (isolate: false), so it uses
the model and the login they have set up. The server picks the CLI from the
name the client sent, and only if it is on PATH.
start says which route a run got:
The two are not the same thing, and the difference is worth knowing:
| sampling | the host's CLI | |
|---|---|---|
| model | the live session's | the user's configured one |
| outlives the session | no — the bridge dies with it | yes |
| cost | the host's own call | a fresh CLI process per reflection |
Sampling also works across a gap worth understanding. start returns in
milliseconds and the run proceeds in a detached process that has no MCP
session and cannot get one, so the server stands up a loopback bridge holding
the live session and hands its address to the run it launches. If that bridge
dies mid-run — the usual reason being that you closed the agent — the run falls
back to the CLI route rather than failing.
Sampling is deprecated at the protocol level
MCP revision 2026-07-28 (SEP-2577) deprecates sampling, along with
roots and logging. The shape is unchanged and the Python SDK still ships
it, so route 1 works today and warns. Route 2 does not depend on it, and
neither do extra_args and isolate.
policies: one reference per slot (selection,task_sampler,acceptance,conflict,fusion,promotion,proposal,staleness), built from JSON scalars alone because the aggregator installs them throughbind/configure(the policy guide).reflective_mergefillsconflictandfusiontogether. Thresholds go inagg_config; decisions go here.evolve: anythingevolve()takes, overriding the kind's defaults.allow: extra import prefixes for your own code. The package's own modules are always allowed and nothing else is; a spec is saved beside its run and shown to the user, so it never carries code or secrets.
agentdescent init <path> writes a starter spec with kind guessed from what
the path is.
Relative paths (target, data.path, a cmd grader) are resolved when the
spec is read, not when it runs: the detached run has the run directory as its
working directory and an MCP server has whatever the host launched it in, so a
relative path would otherwise mean three different files. The spec stored beside
the run holds the resolved paths and is re-runnable from anywhere.
Where a run lives¶
~/.agentdescent/runs/<run_id>/ ($AGENTDESCENT_HOME/runs to move it)
spec.json what was asked
status.json state, round, best reward, calls, dollars if priced — replaced atomically each round
rounds.jsonl one RoundInfo per line
result.json EvolutionResult.save() at the end
tree/ the evolved directory, materialised
ledger/ the git ledger; resume re-launches on it
log.txt the detached process's output
A run outlives the tool call that started it, but it inherits the environment
of the process that launched it — including provider keys and, in sandboxed
or proxied setups, HTTPS_PROXY. That is what you want from an interactive
session, which stays open. It bites in one specific case, measured: a run
started from a headless claude -p kept working until that process exited,
then failed every call with [Errno 111] Connection refused, because the proxy
it had been handed belonged to the process that had gone. The run recorded
state: failed with that error, and agentdescent resume <run_id> from a live
shell picked it up on the same ledger and finished it. If you start runs from a
short-lived host process, expect to resume them from somewhere longer-lived.
agentdescent status, watch, show, apply, cancel and resume all read
this directory, so a run started from an agent can be inspected from a shell
and a run started from a shell can be picked up by an agent. cancel signals
the process group, so the worker CLIs die with the run; resume re-launches the
spec on the same ledger and the engine continues.
A dollar budget (--budget with --usd-per-call, or budget_usd on start)
uses evolve(stop_when=): the run stops between rounds once
calls × price crosses the line and reports the spend it actually incurred.
Evolving the plugins themselves¶
A host plugin — the DSH Cordis package that adds tools and skills to dsh,
a Claude Code plugin directory, a Codex skills-plus-config bundle — is one level
up from a skill, and kind: plugin evolves it. The AgentDescent plugins this
page installs are themselves valid targets.
{"kind": "plugin", "host": "claude_code", "target": "./my-plugin",
"data": {"path": "eval/plugin-cases.jsonl"}, "score": "contains",
"agent": "echo", "reflect": {"ref": "openai_compatible", "model": "deepseek-v4-flash"},
"env_passthrough": ["ANTHROPIC_API_KEY"]}
Each rollout materialises the candidate plugin, loads it into an isolated copy
of the host that lives inside the workspace (HOME is the workspace, so
~/.dsh, ~/.claude and ~/.codex are sandboxed for free), runs the host's
validate gate, then runs the host CLI on the task:
| host | loads the candidate with | gate | worker |
|---|---|---|---|
dsh |
dsh plugin --profile headless add link:<plugin> after pnpm install and pnpm run --if-present build |
pnpm run --if-present test; dsh --profile headless --dump-config composes |
dsh --profile headless "<task>" |
dsh: build and test are optional, and the entrypoint needs a provider
A dsh plugin is often plain ESM with nothing to compile — the one this page
installs is — so both scripts run with --if-present. A package that
declares them still runs them and still fails the gate when they fail; for
one that declares neither, --dump-config is the gate, and it is a real
check that the plugin loads.
The worker's dsh is a fresh profile inside the workspace with none of your
credentials, so the entrypoint stops at MISSING_CREDENTIAL unless you give
it one. Two spec fields do that: entrypoint is appended to the host's
command, and env_passthrough names the variables to carry through.
{"kind": "plugin", "host": "dsh", "target": "./my-dsh-plugin",
"entrypoint": ["--patch", "/abs/path/to/provider.patch.yml"],
"env_passthrough": ["DEEPSEEK_API_KEY"]}
A patch overriding llm-deepseek by id (a bare row, not - insert:) points
it at any OpenAI-compatible endpoint:
| claude_code | claude -p --plugin-dir <plugin> --strict-mcp-config | claude plugin validate <plugin> | the same command |
| codex | skills copied to .agents/skills/, config.toml to .codex/ | the TOML parses | codex exec --sandbox workspace-write --skip-git-repo-check "<task>" |
| opencode | skills copied to .opencode/skills/, opencode.jsonc to .config/opencode/ | the JSON parses | opencode run "<task>" |
A failing gate scores 0 in-band and its text is what the reflector reads, so
"the plugin no longer registers its tool" is a learning signal, not a crashed
round. agent is ignored for this kind — the host is the agent — but must be
present; env_passthrough names the variables the host needs (its provider
key) and never carries values.
Three things are stricter than for the other kinds:
- Governance. A plugin is a harness (
HARNESS_BLAST_RADIUS, L1: every merge through the oracle), and hooks, permission config, lockfiles and tests are frozen by default (PLUGIN_FROZEN), enforced twice — the strategy refuses proposals that touch them and the runner overlays pristine copies after materialisation. A hook that blocks a tool call is the plugin's own L0; an optimiser that could loosen it would be optimising the guard away. - Isolation. Candidate plugin code runs inside the host process with the
host's tool access. Use a container sandbox
(sandboxes) for anything you would not run by hand;
plansays so in its notes. - Recursion. Every worker runs with
AGENTDESCENT_NESTED=1. When the plugin being evolved is the one that hosts this MCP server, the worker'sstartreturns a stub — the transcript still shows the host called it, which is what a grader looks for, and no nested run starts. A self-evolved plugin reaches your real installation only throughapply, a human decision on a diff after the L1 oracle, with a backup: the loop can improve its own skill text and tool descriptions, and it cannot change what is running while it runs.
What "a better plugin" means is up to the dataset. Three task shapes are worth
having: capability tasks (what the plugin adds — gold is the answer),
regression probes ("which tools do you have?" with a cmd grader that
greps for the tool name) and cost probes (claude -p --output-format json
gives turn count and total_cost_usd; a cmd grader folds "under N turns and
under $X" into a score). Reward stays scalar; a multi-objective plugin run is a
task mix.
From Python¶
Nothing here is a second entry point. EvolveSpec is a wire format for callers
that are not Python; compose() produces the same evolve() call the
quickstarts show and is tested against them, so if you are writing Python,
write the call:
from agentdescent import EvolveSpec, compose, load_spec
comp = compose(load_spec(".agentdescent/pdf-audit.evolve.json"))
comp.kwargs["rounds"] # what the spec composed to
result = comp.run() # == evolve(comp.tasks, comp.reward, **comp.kwargs)
Next¶
- Testing the plugins — what to run for each host, and what no test can tell you
- Quickstart — evolve a directory — the Python call
the
skill_dirkind composes - Using the policy slots — what the
policiesblock can name - Sandboxes — the container provider the
pluginkind wants - Design record — why it is built this way