Cross-runner: OpenCode via the CLI runner¶
The same eval.yaml pipeline — dataset, judges, thresholds, MLflow, reports — works
with any agent CLI, not just Claude Code. This recipe wraps
OpenCode with the opaque CLI runner
(runner.type: cli): a command template with placeholders, execution.env variable
injection, a metrics.json for cost reporting, and inline check judges that verify the
agent's output.
What you'll build
A smoke test that asks OpenCode to create a file, copies new/modified files into the harness output dir, extracts token/cost metrics from OpenCode's JSON event stream, and scores the result with two inline judges.
How the CLI runner fits¶
Unlike runner.type: claude-code, the CLI runner treats the agent as a black box: the
harness resolves placeholders, runs your command, and reads back files, stdout/stderr,
exit code, and metrics.json. Everything Claude-Code-specific (stream-json tracing, tool
interception, subagent capture, budget enforcement) is off — see the
gotchas below.
flowchart LR
A[eval.yaml<br/>runner.type: cli] --> B[cli_runner.py]
B -->|resolve placeholders<br/>+ execution.env| C[bash wrapper<br/>run-opencode.sh]
C --> D[opencode run]
D --> E[workspace files]
C -->|copy new/modified| F[output_dir]
C -->|parse event stream| G[metrics.json]
F --> H[collect.py]
G --> H
H --> I[inline check judges]
I --> J[report.html + MLflow]
The config¶
name: opencode-smoke
description: Smoke test for OpenCode CLI agent — creates a file and verifies content
execution:
mode: case
skill: opencode-smoke
arguments: "{prompt}"
timeout: 120
max_budget_usd: 1.0
env:
GOOGLE_CLOUD_PROJECT: "$GOOGLE_CLOUD_PROJECT"
VERTEX_LOCATION: us-east5
EVAL_PROJECT_ROOT: "$PWD"
OPENCODE_ENABLE_TELEMETRY: "1"
OPENCODE_OTLP_ENDPOINT: "$OPENCODE_OTLP_ENDPOINT"
OPENCODE_OTLP_PROTOCOL: http/json
OPENCODE_OTLP_HEADERS: "$OPENCODE_OTLP_HEADERS"
OTEL_BSP_SCHEDULE_DELAY: "100"
runner:
type: cli
command:
- bash
- -c
- exec "$EVAL_PROJECT_ROOT/eval/opencode-smoke/scripts/run-opencode.sh" "$@"
- --
- "{args}"
- "{workspace}"
- "{output_dir}"
- "{model}"
models:
skill: google-vertex/claude-haiku-4-5@20251001
mlflow:
experiment: opencode-smoke
tracking_uri: https://mlflow.example.com
tags:
test_type: smoke
agent: opencode
dataset:
path: cases
schema: |
Each case directory contains:
- input.yaml: prompt (the task for opencode to execute)
- annotations.yaml: expected_file, expected_content (used by judges)
outputs:
- path: output
schema: Files created by opencode during execution
traces:
stdout: true
stderr: true
events: false
metrics: true
judges:
- name: exit_success
description: OpenCode exited successfully
check: |
ec = outputs.get("exit_code", -1)
if ec == 0:
return True, "Exit code 0"
return False, f"Exit code {ec}"
- name: file_created
description: Expected file exists with correct content
check: |
ann = outputs.get("annotations", {})
expected_file = ann.get("expected_file", "")
expected_content = ann.get("expected_content", "")
if not expected_file:
return False, "No expected_file in annotations"
files = outputs.get("files", {})
for path, content in files.items():
if expected_file in path and isinstance(content, str):
if expected_content and expected_content in content:
return True, f"Found {expected_file} with expected content"
elif not expected_content:
return True, f"Found {expected_file}"
else:
return False, f"Found {expected_file} but content mismatch: {content[:200]}"
return False, f"File {expected_file} not found in outputs: {list(files.keys())[:10]}"
thresholds:
exit_success:
min_pass_rate: 1.0
file_created:
min_pass_rate: 1.0
Sanitize before committing
The values above are generic placeholders. Point mlflow.tracking_uri at your own
server (https://mlflow.example.com), set your own GOOGLE_CLOUD_PROJECT, and never
commit real endpoints, project IDs, or credentials.
The command template¶
runner.command is a list — argv, not a shell string — and the harness substitutes
placeholders before running it. Here it invokes bash -c with an exec wrapper so the
positional args ("$@") are forwarded to the script after --:
| Position | Placeholder | Becomes |
|---|---|---|
{args} |
Resolved execution.arguments ({prompt} filled from input.yaml) |
the task prompt |
{workspace} |
Absolute path to the per-case workspace | cwd for OpenCode |
{output_dir} |
{workspace}/output, pre-created by the runner |
where artifacts land |
{model} |
--model flag or models.skill |
google-vertex/claude-haiku-4-5@… |
Full placeholder list
{subagent_model}, {timeout}, {max_budget_usd}, {effort}, {system_prompt},
{agent}, and any {field} from input.yaml are also available. See the
opaque CLI runner contract
and runner reference.
The wrapper script¶
The wrapper is where the cross-runner glue lives. It must satisfy the runner contract:
exit 0/non-zero, write artifacts to {output_dir}, finish before {timeout}, and — to
get cost data — write {output_dir}/metrics.json.
#!/usr/bin/env bash
# Usage: run-opencode.sh <prompt> <workspace> <output_dir> [model]
set -euo pipefail
PROMPT="$1"; WORKSPACE="$2"; OUTPUT_DIR="$3"
MODEL="${4:-google-vertex/claude-haiku-4-5@20251001}"
OPENCODE="${OPENCODE_BIN:-$(command -v opencode 2>/dev/null || echo "$HOME/.opencode/bin/opencode")}"
mkdir -p "$WORKSPACE" "$OUTPUT_DIR"
cd "$WORKSPACE"
# 1. Snapshot files (checksums) to detect new + modified files
PRE=$(mktemp); POST=$(mktemp); EVENTS=$(mktemp)
trap 'rm -f "$PRE" "$POST" "$EVENTS"' EXIT
find . -maxdepth 3 -type f -not -path './.git/*' -not -path './output/*' \
-exec md5sum {} + 2>/dev/null | sort > "$PRE" || true
# 2. Run opencode, capturing its JSON event stream (also streamed to stdout)
set +e
"$OPENCODE" run "$PROMPT" --format json --dir "$WORKSPACE" --model "$MODEL" --auto \
2>&1 | tee "$EVENTS"
EXIT_CODE=${PIPESTATUS[0]}
set -e
# 3. Copy new/modified files into output_dir
find . -maxdepth 3 -type f -not -path './.git/*' -not -path './output/*' \
-exec md5sum {} + 2>/dev/null | sort > "$POST" || true
comm -13 "$PRE" "$POST" | awk '{print $2}' | while IFS= read -r f; do
mkdir -p "$OUTPUT_DIR/$(dirname "$f")"; cp "$f" "$OUTPUT_DIR/$f" 2>/dev/null || true
done
# 4. Extract token/cost metrics from step_finish events → metrics.json
# (see the full script for the python one-liner)
exit $EXIT_CODE
Full script
The excerpt trims the metrics-extraction step. See the complete version at
eval/opencode-smoke/scripts/run-opencode.sh.
metrics.json is how cost reporting works¶
For an opaque CLI runner, metrics.json is the only channel for token/cost data — the
harness can't observe the agent's usage otherwise. The wrapper sums OpenCode's
step_finish events into the contract's schema:
{
"token_usage": {"input": 1500, "output": 800, "cache_read": 0, "cache_write": 0},
"cost_usd": 0.0031,
"num_turns": 4,
"model": "google-vertex/claude-haiku-4-5@20251001"
}
| Field | Feeds |
|---|---|
token_usage |
Report token breakdown, MLflow metrics, cost-per-turn |
cost_usd |
Report cost column, MLflow run metric, regression comparison |
num_turns |
Turn count and cost-per-turn ratio |
model |
Report model column, resolved_model in the result |
No metrics.json → empty cost tables
If the wrapper doesn't write metrics.json, token_usage, cost_usd, and
num_turns all report as None and cost tables in the report are blank.
Environment injection¶
execution.env forwards variables into the wrapper's process. Values using $VAR syntax
are resolved from the caller's environment at run time; literal values pass through
verbatim.
| Pattern | Example | Resolves to |
|---|---|---|
"$VAR" |
GOOGLE_CLOUD_PROJECT: "$GOOGLE_CLOUD_PROJECT" |
The caller's $GOOGLE_CLOUD_PROJECT |
"$PWD" |
EVAL_PROJECT_ROOT: "$PWD" |
Absolute project root (used in runner.command) |
| literal | VERTEX_LOCATION: us-east5 |
The string us-east5 |
The CLI runner inherits the full environment
Unlike the Claude Code runner (which filters env to a safe allowlist), the opaque CLI
runner inherits the caller's full os.environ — commands are authored by you, not by
an untrusted agent. execution.env layers on top. runner.env has no effect for
the CLI runner since the full env is already inherited.
Inline check judges¶
Because there's no stream-json to mine, judging leans on files and exit code. Inline
check judges are Python snippets that receive an outputs dict and return
(passed: bool, rationale: str):
exit_successreadsoutputs["exit_code"](populated becausetraces.metrics: true).file_createdreadsoutputs["files"](artifacts collected fromoutput/) andoutputs["annotations"](from each case'sannotations.yaml) to confirm the expected file exists with the expected content.
outputs key |
Where it comes from |
|---|---|
files |
Files collected from outputs[*].path dirs |
annotations |
The case's annotations.yaml (not runner-produced) |
exit_code, duration_s |
run_result.json (needs traces.metrics) |
token_usage, cost_usd, num_turns |
metrics.json written by the wrapper |
stdout, stderr |
Captured logs (need traces.stdout / traces.stderr) |
See judges and the judges reference for the other three judge types.
Run it¶
The report, MLflow logging, --baseline pairwise comparison, and
thresholds all work exactly as they do for Claude Code — the
runner is the only thing that changed.
What doesn't carry over¶
The opaque CLI runner is a black box, so Claude-Code-specific features are unavailable:
| Feature | Why it's off |
|---|---|
| Budget enforcement | The harness can't throttle an opaque process; {max_budget_usd} is advisory only |
| Tool interception / AskUserQuestion answering | Requires Claude Code PreToolUse hooks |
| Stream-json trace building | Requires --output-format stream-json |
| Subagent transcript capture | Requires the SubagentStop hook |
| Permission-denial detection & real-time progress | Require stream-json events |
See also¶
- Runners — the runner abstraction and each
type - runner reference —
type,command, and all knobs - execution reference —
env,timeout,max_budget_usd - Skill eval (case mode) — the same pipeline with Claude Code