Clash API Node Switching: Advanced Automation Guide

What Clash API Automation Should Actually Do Explained

Most Clash users begin with a manually selected proxy group: open the client, click a node, wait for a page to load, and repeat the process when the connection becomes slow. That approach is acceptable for occasional browsing, but it becomes fragile when Clash is used for development tools, scheduled jobs, remote administration, API clients, or services that must remain reachable for hours. A node can remain present in the subscription while its TLS handshake fails, its route becomes congested, or its latency changes sharply during the day.

The Clash external controller API gives you a way to turn that manual process into a small failover system. A script can ask Mihomo or another Clash-compatible core which proxies exist, test selected nodes through the controller, compare their measured latency, and move a proxy group to a working node. The important distinction is that the script does not need to rewrite the entire YAML profile. It can operate on the running configuration through authenticated HTTP requests, leaving rules, DNS settings, ports, and policy groups intact.

This guide focuses on a practical Clash API node switching design rather than a theoretical monitoring platform. You will learn how to expose the controller safely, discover proxy names, test latency with the correct URL, update a selector group, and add safeguards so a temporary timeout does not cause constant switching. The examples use Shell and Python because they are available on most Windows, macOS, and Linux systems. The same API concepts also apply when the controller is provided by Clash Verge Rev, Mihomo Party, Clash for Android, or another Mihomo-based client.

Core compatibility matters: endpoint names and response fields can differ slightly between legacy Clash, Clash Meta, and Mihomo releases. Test the API manually first, and treat the response from your installed core as the source of truth rather than copying an old screenshot or undocumented example.

Prepare and Secure the External Controller in Detail

The controller is an HTTP interface for the running proxy core. It commonly exposes endpoints for reading the current configuration, listing proxies, measuring delay, and changing the active member of a proxy group. A typical local address is 127.0.0.1:9090, although the port may be different in your client. The controller secret is usually configured as secret in the external-controller section of a YAML profile or in the client’s advanced settings.

Do not bind the controller to every network interface unless you have a specific administrative reason and a firewall policy that limits access. A controller exposed on 0.0.0.0 can become a remote control panel for your proxy process. Anyone who can reach it may be able to select nodes, inspect connection information, or change runtime behaviour. For a local automation script, binding to 127.0.0.1 is normally sufficient. If automation runs on another machine, place the controller behind a private network, firewall rule, or authenticated tunnel instead of publishing it directly to the internet.

Use the Authorization header for API requests when a secret is configured. In many Mihomo-compatible implementations the format is Bearer YOUR_SECRET. Keep the secret out of shell history, public repositories, screenshots, and process arguments when possible. A local environment variable or a file readable only by your user account is safer than embedding the value in every script. On Unix-like systems, a credentials file should normally use permissions such as chmod 600.

Test the controller before writing automation Explained

Start with a harmless read request. Replace the port and secret with the values from your own client:

export CLASH_API="http://127.0.0.1:9090"
export CLASH_SECRET="replace-with-your-secret"

curl --fail --silent \
  -H "Authorization: Bearer ${CLASH_SECRET}" \
  "${CLASH_API}/proxies"

A successful response is a JSON object containing proxy definitions and, in most versions, a special GLOBAL or DIRECT entry. If you receive 401 Unauthorized, check the secret and header format. A connection refusal usually means that the core is not running, the port is wrong, or the client uses a different controller address. A successful response with unexpected proxy names is not an error: subscription providers often include nested groups, load-balancing groups, fallback groups, and policy groups alongside individual nodes.

Before changing anything, identify the group that your rules actually use. For example, a rule may reference PROXY, while another profile may use 🚀 Proxy, Auto, or a provider-specific name. Switching a group that is not referenced by any active rule will produce a technically successful API response but no visible traffic change. Read the active configuration or inspect the client’s proxy-group panel to confirm the exact group name, including spaces, emoji, punctuation, and capitalization.

Choose a Useful Node Health Test Made Clear

A latency test is not the same as a complete guarantee that a node will work for every destination. The controller usually measures a proxy by requesting a URL through that proxy and reporting the elapsed time. This is useful for detecting dead transports, broken TLS, and obviously congested nodes, but it does not prove that every website, API, or streaming service is reachable. A node may be fast against a small test endpoint and still be unsuitable for a particular destination because of routing restrictions or remote-server policy.

Choose a stable HTTPS endpoint that returns quickly and does not require login. Many Clash-compatible cores accept a request shaped like this:

curl --fail --silent \
  -H "Authorization: Bearer ${CLASH_SECRET}" \
  --get "${CLASH_API}/proxies/Example%20Node/delay" \
  --data-urlencode "url=https://www.gstatic.com/generate_204" \
  --data-urlencode "timeout=5000"

The proxy name must be URL-encoded. A name such as US Premium 01 cannot safely be inserted into a path with literal spaces. The API may return a JSON object containing a delay value in milliseconds, or an error when the request times out. Some cores require a different test URL or expose a slightly different delay endpoint, so confirm the route in your installed version if this request returns a 404.

Use a timeout that reflects the job. A two-second test is useful for interactive browsing but may reject a node during a brief congested period. A ten-second test reduces false negatives but makes failover slower. For a personal workstation, five seconds is a reasonable starting point. You can also perform two tests several hundred milliseconds apart and accept a node only when both succeed. That costs more requests, but it prevents one transient packet loss event from changing the active route.

Latency alone should not be the only selection rule. A good candidate filter may exclude groups, DIRECT, REJECT, expired provider entries, and nodes whose names match words such as “ทดลอง” or “backup” when your provider uses those labels for special-purpose entries. If the provider exposes a dedicated group of stable nodes, prefer that group instead of scanning every object returned by /proxies.

Do not test too aggressively. A script that checks dozens of nodes every few seconds can create unnecessary traffic, trigger provider limits, and make the controller appear unstable. Start with a five- or ten-minute interval, test only a small candidate set, and add a cooldown after every successful switch.

Build a Safe Automatic Switching Workflow Under the Hood

The most reliable design separates discovery, testing, decision-making, and switching. Discovery reads the current proxy list. Testing measures only eligible leaf nodes. Decision-making compares successful results with the current node and applies a meaningful improvement threshold. Switching updates one known group. Finally, verification checks that the group really changed and records the result in a log.

  1. Read the active proxy list: request /proxies and parse the JSON response instead of maintaining a second hard-coded list that becomes stale after every subscription update.
  2. Identify the target group: choose the exact group used by your rules, then read its current selected member before making a change.
  3. Filter candidates: ignore DIRECT, REJECT, nested groups, and any node that your local naming policy marks as unsuitable.
  4. Measure candidates: call the delay endpoint with a bounded timeout and store both successful latency and failure details.
  5. Apply a decision threshold: switch only when the current node fails or the best candidate is faster by a configured margin, such as 20 percent or 150 milliseconds.
  6. Update the group: send a URL-encoded request to the group’s selection endpoint, then wait briefly for the core to apply the change.
  7. Verify and cool down: read the group again, write a timestamped log entry, and prevent another switch until the cooldown expires.

A compact Shell implementation in Brief

The following script demonstrates the core pattern. It assumes that your controller supports the common GET /proxies, GET /proxies/{name}/delay, and PUT /proxies/{group} endpoints. Adjust the group name and test URL, and install jq if your system does not already provide it.

#!/usr/bin/env bash
set -euo pipefail

API="${CLASH_API:-http://127.0.0.1:9090}"
SECRET="${CLASH_SECRET:?Set CLASH_SECRET first}"
GROUP="${CLASH_GROUP:-PROXY}"
TEST_URL="${CLASH_TEST_URL:-https://www.gstatic.com/generate_204}"
TIMEOUT="${CLASH_TIMEOUT:-5000}"
MIN_GAIN="${CLASH_MIN_GAIN:-150}"

auth=(-H "Authorization: Bearer ${SECRET}")

current="$(curl --fail --silent "${auth[@]}" \
  "${API}/proxies/${GROUP// /%20}" | jq -r '.now')"

mapfile -t candidates < <(curl --fail --silent "${auth[@]}" \
  "${API}/proxies" |
  jq -r --arg group "$GROUP" '
    .proxies[$group].all[]?
    | select(. != "DIRECT" and . != "REJECT")
  ')

best_name=""
best_delay=999999

for name in "${candidates[@]}"; do
  encoded="$(jq -rn --arg v "$name" '$v|@uri')"
  result="$(curl --silent --show-error \
    --max-time 8 "${auth[@]}" --get \
    "${API}/proxies/${encoded}/delay" \
    --data-urlencode "url=${TEST_URL}" \
    --data-urlencode "timeout=${TIMEOUT}" || true)"
  delay="$(jq -r '.delay // empty' <<< "$result")"

  if [[ "$delay" =~ ^[0-9]+$ ]] && (( delay < best_delay )); then
    best_name="$name"
    best_delay="$delay"
  fi
done

if [[ -z "$best_name" ]]; then
  echo "No healthy candidate found" >&2
  exit 1
fi

if [[ "$best_name" == "$current" ]]; then
  echo "Keep ${current}; best delay ${best_delay} ms"
  exit 0
fi

curl --fail --silent --request PUT "${auth[@]}" \
  --header "Content-Type: application/json" \
  --data "$(jq -n --arg name "$best_name" '{name:$name}')" \
  "${API}/proxies/${GROUP// /%20}" > /dev/null

echo "Switched ${GROUP}: ${current} -> ${best_name} (${best_delay} ms)"

This example intentionally stays conservative: it switches when the best candidate is available, but a production version should compare the best result with the current node’s fresh delay. Otherwise, an improvement of one millisecond can cause needless movement. Add a current-node test, a minimum improvement threshold, and a failure counter before using the script for a long-running service.

Python automation with hysteresis in Detail

Python is useful when you want clearer error handling, persistent state, or a more sophisticated policy. The following example uses the standard library only. It tests a supplied list of node names, keeps the current node when it is still healthy, and requires the new node to be meaningfully better before switching.

import json
import os
import time
import urllib.parse
import urllib.request

API = os.getenv("CLASH_API", "http://127.0.0.1:9090")
SECRET = os.environ["CLASH_SECRET"]
GROUP = os.getenv("CLASH_GROUP", "PROXY")
TEST_URL = os.getenv("CLASH_TEST_URL", "https://www.gstatic.com/generate_204")
TIMEOUT_MS = int(os.getenv("CLASH_TIMEOUT", "5000"))
MIN_GAIN_MS = int(os.getenv("CLASH_MIN_GAIN", "150"))

headers = {"Authorization": f"Bearer {SECRET}"}

def request(path, method="GET", payload=None):
    body = None
    local_headers = dict(headers)
    if payload is not None:
        body = json.dumps(payload).encode()
        local_headers["Content-Type"] = "application/json"
    req = urllib.request.Request(
        API + path, data=body, headers=local_headers, method=method
    )
    with urllib.request.urlopen(req, timeout=8) as response:
        return json.loads(response.read().decode())

def quote_name(name):
    return urllib.parse.quote(name, safe="")

def delay_for(name):
    path = f"/proxies/{quote_name(name)}/delay"
    query = urllib.parse.urlencode({"url": TEST_URL, "timeout": TIMEOUT_MS})
    try:
        data = request(f"{path}?{query}")
        return int(data["delay"])
    except Exception:
        return None

data = request("/proxies")
group = data["proxies"][GROUP]
current = group.get("now")
candidates = [
    name for name in group.get("all", [])
    if name not in {"DIRECT", "REJECT"} and name != GROUP
]

results = {name: delay_for(name) for name in candidates}
healthy = {name: delay for name, delay in results.items()
           if delay is not None}

if not healthy:
    raise SystemExit("No healthy proxy candidate")

best = min(healthy, key=healthy.get)
current_delay = delay_for(current) if current else None

if current_delay is not None and healthy[best] + MIN_GAIN_MS >= current_delay:
    print(f"Keep {current}: current={current_delay} ms, best={healthy[best]} ms")
else:
    request(f"/proxies/{quote_name(GROUP)}", "PUT", {"name": best})
    time.sleep(0.5)
    confirmed = request(f"/proxies/{quote_name(GROUP)}").get("now")
    print(f"Selected {confirmed}; delay={healthy[best]} ms")

The use of hysteresis is important. Without it, two nodes with nearly identical latency can alternate on every polling cycle. The MIN_GAIN_MS value creates a buffer: the new node must be substantially better before it replaces the current one. You can also require three consecutive failures before abandoning the current node, while allowing one successful test to restore it later.

Guardrails for Long-Running Automation Explained

A node switch can interrupt active downloads, WebSocket sessions, remote shells, and API requests. For that reason, the automation should be aware of what it is controlling. If the group carries a long-lived connection, prefer switching only after a confirmed failure instead of optimising every small latency difference. If the group is used for short browser requests, a faster-node policy may be acceptable. Separate groups are often the cleanest solution: one stable group for work services, one flexible group for ordinary browsing, and one manually controlled group for debugging.

Subscription updates can rename or remove nodes. A script should handle a missing group and an empty candidate list without replacing the active profile or writing a broken configuration. Log the controller status, target group, current selection, candidate count, selected latency, and reason for every decision. Avoid logging full subscription URLs because they may contain credentials. When a script fails, the log should tell you whether the failure was authentication, JSON parsing, timeout, missing node, or an API compatibility issue.

  • Use a lock: prevent two scheduled jobs from testing and switching the same group simultaneously.
  • Set a cooldown: wait several minutes after a switch before allowing another one.
  • Limit concurrency: test a small number of nodes at a time so the client and provider are not flooded.
  • Keep a fallback: define a known-good node or a manual group for recovery when every automated candidate fails.
  • Verify the destination: latency to the test URL should be followed by a real request to the service that matters.
  • Respect privacy: do not expose controller responses, proxy names, or secrets in public monitoring dashboards.

For scheduling, Task Scheduler on Windows, launchd on macOS, and systemd timers or cron on Linux can run the checker. A timer interval is generally preferable to a permanently running loop because it is easier to inspect and restart. Whatever scheduler you use, make sure the environment variables containing the controller URL and secret are available to the job. Scheduled processes often run with a different working directory and a reduced environment, which explains many “works in my terminal” reports.

Troubleshoot Failed API Switching in Detail

If /proxies works but the delay request fails, first inspect the encoded node path and test URL. Names containing slashes, brackets, spaces, emoji, or non-Latin characters must be percent-encoded. A failure may also mean that the selected object is a group rather than a leaf proxy. Read the object’s metadata and test only entries that represent actual outbound nodes.

If the delay request succeeds but the PUT request returns an error, verify that the target is a selectable group. Some special groups are read-only, while a global mode or a provider-managed group may not accept manual selection. Confirm that the request body is JSON with a name property and that the group name in the URL is encoded. After a successful response, issue a second GET request to confirm the group’s now value.

If the script changes the group but applications continue using the old route, inspect the active rules and connection view. The application may match a different group, use a cached connection, bypass the system proxy, or be captured by TUN rules that do not follow the group you changed. Existing TCP and WebSocket sessions may remain on their original path until they close. Test with a new request rather than assuming that every open connection moves instantly.

Debug in layers: first prove that the controller is reachable, then prove that one known node passes a delay test, then prove that the target group accepts a selection update, and only afterward add scheduling. Building all four layers at once makes a simple URL-encoding or group-name error look like a core failure.

Frequently Asked Questions Made Clear

Should I expose the Clash controller to my LAN? Notes

Usually not. A local automation script should use a loopback controller bound to 127.0.0.1. If another device must manage the core, restrict access with a firewall and private network, keep a strong secret, and consider an authenticated tunnel. Never assume that an obscure port is a security mechanism.

Does the lowest latency node always provide the best connection? Explained

No. The delay endpoint measures one URL and one route at one moment. It does not measure every destination, upload stability, packet loss, streaming performance, or service compatibility. Use latency as a screening signal, then verify the actual workload and avoid switching for tiny differences.

Why does automatic switching keep oscillating between two nodes? in Brief

Oscillation normally comes from a policy with no hysteresis. Add a minimum improvement threshold, consecutive-failure requirements, and a cooldown period. Also avoid testing a node immediately after switching to it; give the new route time to establish connections before judging it.

Which Clash client is suitable for API-based automation? in Detail

Choose a maintained client that exposes a compatible Mihomo or Clash Meta controller and lets you configure a secret. Clash Verge Rev and Mihomo-based desktop clients are convenient choices, while mobile clients may expose fewer background-automation options because of operating-system restrictions. Confirm the actual endpoints supported by the core bundled with your client.

Compared with GUI-only proxy managers, provider-specific scripts, or older clients that require editing YAML for every node change, an API workflow gives you repeatable health checks, explicit thresholds, and a clear audit trail; those alternatives may still be useful, but they often hide selection state, lack reliable controller documentation, or become difficult to maintain after a subscription update. Clash is a practical fit for this scenario because its Clash-oriented guidance keeps client setup, Mihomo controller details, security precautions, and Shell or Python examples in one workflow instead of treating automation as an unexplained black box. Once you have confirmed your controller port and chosen a safe fallback policy, you can download a suitable Clash client and build the failover process step by step without giving up manual control.