Configuration Featured Clash Beginner Guide Clash vs VPN Proxy Basics

Clash API Auto-Switch Nodes: Advanced Scripting Guide 2026

August 24, 2026 Updated August 24, 2026 Approx. 12 min read

Why Automate Clash Node Switching?

Manual proxy switching is acceptable when you are browsing casually, but it becomes a serious productivity problem during long-running development jobs. A package installation, Docker image pull, remote build, Git fetch, or deployment task can continue for several minutes while the selected node becomes congested or unreachable. By the time you notice the failure, the process may have already timed out or left behind an incomplete artifact.

Clash exposes a local REST API that allows scripts to inspect the running core, query proxy groups, test individual nodes, and change the active selection. With a small amount of automation, you can turn a static proxy profile into a resilient routing system. The script in this guide measures node latency, filters unsuitable endpoints, ranks healthy candidates, and updates a selected proxy group without requiring you to open the graphical client.

This approach works especially well with Clash Verge Rev, Mihomo, and other clients that expose the Clash-compatible controller API. Endpoint names and supported fields can vary slightly between cores, so always verify the API behavior of your installed client before using an automated switcher on a production machine.

Automation Goal

Select a responsive node automatically while preserving authentication, useful logs, retry limits, and a safe fallback when every candidate fails.

1Preparing the Clash Controller API

The controller API is normally bound to a local address such as 127.0.0.1:9090. The port is controlled by the external-controller setting in the active YAML profile. A typical local configuration looks like this:

external-controller: 127.0.0.1:9090 secret: "replace-this-with-a-long-random-token"

The secret value is sent in the Authorization header as a Bearer token. Some clients use secret: with an empty value, but an unauthenticated controller is risky even when it listens only on localhost. Any local application with access to the port could potentially inspect your nodes or change your active proxy group.

Verify the Controller Before Writing a Script

First, confirm that the controller is reachable and that the token is accepted. The /version endpoint is a convenient health check because it does not depend on a particular proxy group or subscription format.

curl -H "Authorization: Bearer replace-this-with-a-long-random-token" \ http://127.0.0.1:9090/version

A successful response normally contains the running core name and version. If you receive 401 Unauthorized, check the token and header format. A connection refusal usually means that the controller is disabled, the port is different, or the client is bound to another address. Do not solve this by exposing the controller to the public internet. If remote administration is genuinely required, protect it with a firewall, a private network, and an additional access layer.

Security Warning

Never publish the controller port or place its token directly in a shared repository. The token can provide enough authority to redirect traffic, reveal proxy metadata, and modify running groups.

Choose a Stable Group Name

Automation should control a dedicated proxy group rather than an arbitrary node. In your YAML configuration, create or identify a group such as Auto Select. The group must contain the nodes that the script is allowed to test and select. Keeping the candidate list explicit prevents the script from accidentally choosing a subscription entry, a nested selector, or a node intended for a different purpose.

proxy-groups: - name: Auto Select type: select proxies: - Tokyo-01 - Singapore-01 - Frankfurt-01 - DIRECT

Group names and proxy names are case-sensitive. If your provider uses emoji, brackets, or non-ASCII labels, copy the names exactly as returned by the API instead of retyping them. The script will discover the members dynamically, but the target group still has to exist in the active profile.

2Testing and Ranking Candidate Nodes

A node should not be selected solely because its name contains a preferred location. A server may be geographically close but overloaded, rate-limited, or unable to reach the destination required by your development workflow. The Clash API provides a delay test for a specific proxy through an endpoint similar to /proxies/{proxy}/delay.

The test requires a URL and a timeout. Use a small, stable HTTPS endpoint rather than a large download. The purpose is to measure reachability and approximate response time, not to benchmark bandwidth. A useful test URL should return quickly, support TLS, and be available from the regions you intend to use. You can change the URL to match your real workload, but avoid testing a private company endpoint unless your network policy permits it.

  • Timeout: Use approximately 2,000 to 5,000 milliseconds. A very short timeout creates false failures on busy networks.
  • Sample count: One request is simple, while three samples provide a more stable ranking at the cost of additional requests.
  • Eligibility: Exclude DIRECT, REJECT, nested groups, and any node name explicitly placed on a deny list.
  • Freshness: Run the test immediately before switching, because latency can change quickly during peak hours.
  • Failure handling: Treat timeouts, connection errors, invalid JSON, and non-success HTTP responses as failed candidates.

Latency Is Not the Only Signal

Latency ranking is useful, but it is not a complete quality score. A node with a 90 millisecond response may still perform poorly for large package downloads if it has limited throughput. Conversely, a node with 180 milliseconds may be more reliable for a long-lived SSH session. For this reason, the example uses latency as a first-stage filter and gives equal importance to successful responses and retry behavior.

You can extend the scoring model later by recording several measurements, calculating a median rather than a minimum, or adding a lightweight download test. Keep the first version conservative. A switcher that changes nodes too aggressively can interrupt WebSocket sessions, invalidate download connections, and make troubleshooting harder.

Practical Tip

Set a minimum improvement threshold before switching. If the current node is working at 180 ms and the best candidate measures 175 ms, keeping the current route may be safer than creating a needless connection change.

3Building a Safe Auto-Switch Script

The following Python example uses only the standard library, so it can run on Windows, macOS, or Linux without installing an additional HTTP package. It authenticates every request, records useful events, retries temporary API failures, ignores unsuitable members, and restores the previous selection if the switch request fails. Save it as clash_auto_switch.py and adjust the configuration values near the top.

import json import logging import os import time import urllib.parse import urllib.request import urllib.error CONTROLLER = os.getenv("CLASH_CONTROLLER", "http://127.0.0.1:9090") TOKEN = os.getenv("CLASH_SECRET", "") GROUP = os.getenv("CLASH_GROUP", "Auto Select") TEST_URL = os.getenv("CLASH_TEST_URL", "https://www.gstatic.com/generate_204") TIMEOUT_MS = 3500 RETRIES = 2 MIN_IMPROVEMENT_MS = 15 EXCLUDED = {"DIRECT", "REJECT"} logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) def request_json(method, path, payload=None, query=None): url = CONTROLLER.rstrip("/") + path if query: url += "?" + urllib.parse.urlencode(query) body = None headers = {"Accept": "application/json"} if TOKEN: headers["Authorization"] = "Bearer " + TOKEN if payload is not None: body = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" request = urllib.request.Request( url, data=body, headers=headers, method=method ) last_error = None for attempt in range(RETRIES + 1): try: with urllib.request.urlopen(request, timeout=8) as response: raw = response.read().decode("utf-8") return response.status, json.loads(raw) if raw else {} except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as exc: last_error = exc if attempt < RETRIES: delay = 1.5 ** attempt logging.warning("API request failed; retrying in %.1fs: %s", delay, exc) time.sleep(delay) raise RuntimeError("API request failed after retries: %s" % last_error) def proxy_name_path(name): return "/proxies/" + urllib.parse.quote(name, safe="") def current_group_member(): status, data = request_json("GET", proxy_name_path(GROUP)) if status != 200 or "now" not in data: raise RuntimeError("Group has no current selection: " + GROUP) return data["now"] def group_members(): status, data = request_json("GET", proxy_name_path(GROUP)) if status != 200: raise RuntimeError("Unable to read group: " + GROUP) return data.get("all", []) def test_proxy(name): path = proxy_name_path(name) + "/delay" query = {"url": TEST_URL, "timeout": TIMEOUT_MS} status, data = request_json("GET", path, query=query) if status != 200 or "delay" not in data: raise RuntimeError("No delay result for " + name) return int(data["delay"]) def select_proxy(name): status, data = request_json( "PUT", proxy_name_path(GROUP), {"name": name} ) if status not in (200, 204): raise RuntimeError("Selection failed for " + name) return data def main(): if not TOKEN: raise RuntimeError("Set CLASH_SECRET before running the script") previous = current_group_member() candidates = [ name for name in group_members() if name not in EXCLUDED and name != GROUP ] results = {} for name in candidates: try: delay = test_proxy(name) results[name] = delay logging.info("Healthy node: %s (%d ms)", name, delay) except Exception as exc: logging.warning("Skipping %s: %s", name, exc) if not results: logging.error("No healthy candidates; keeping %s", previous) return 2 best_name, best_delay = min(results.items(), key=lambda item: item[1]) current_delay = results.get(previous) if current_delay is not None and \ best_delay + MIN_IMPROVEMENT_MS >= current_delay: logging.info("Keeping %s; improvement is too small", previous) return 0 if best_name == previous: logging.info("Already using best node: %s", previous) return 0 logging.info("Switching %s -> %s (%d ms)", previous, best_name, best_delay) try: select_proxy(best_name) time.sleep(2) if current_group_member() != best_name: raise RuntimeError("Controller did not confirm the new selection") logging.info("Switch confirmed: %s", best_name) return 0 except Exception as exc: logging.error("Switch failed: %s; restoring %s", exc, previous) try: select_proxy(previous) logging.info("Previous selection restored") except Exception as restore_error: logging.critical("Recovery failed: %s", restore_error) return 1 if __name__ == "__main__": raise SystemExit(main())

How the Script Works

The script first reads the current member of the target group, then obtains the group member list. Every candidate is tested individually. The URL path is encoded with urllib.parse.quote, which is important when a proxy name contains spaces, slashes, brackets, or other characters that have a special meaning in a URL.

The retry wrapper handles temporary failures without creating an uncontrolled loop. It retries only a small, fixed number of times and applies a short backoff between attempts. This distinction matters: an automation task should fail visibly rather than continue sending requests forever when the controller is offline.

After all tests finish, the script selects the lowest delay. The MIN_IMPROVEMENT_MS threshold prevents unnecessary switching when two nodes have nearly identical results. It then sends a PUT request to the group endpoint with a JSON body containing the selected proxy name. Finally, it queries the group again and confirms that the controller accepted the change.

4Deployment, Scheduling, and Recovery

Do not place the secret directly in the script if the file will be synchronized, backed up, or committed to source control. Set it through an environment variable instead. On macOS and Linux, a temporary shell session can use:

export CLASH_CONTROLLER="http://127.0.0.1:9090" export CLASH_SECRET="replace-this-with-a-long-random-token" export CLASH_GROUP="Auto Select" python3 clash_auto_switch.py

On Windows PowerShell, use the equivalent syntax:

$env:CLASH_CONTROLLER = "http://127.0.0.1:9090" $env:CLASH_SECRET = "replace-this-with-a-long-random-token" $env:CLASH_GROUP = "Auto Select" python .\clash_auto_switch.py

For recurring checks, schedule the script conservatively. Running it every few minutes may be appropriate for a mobile connection, but running it every few seconds can cause route flapping and unnecessary API traffic. A development workstation can usually start with a 10 to 30 minute interval, then adjust after reviewing the logs.

  • Linux: Use a user-level systemd timer or a restrained cron entry. Make sure the environment variables are available to the scheduled process.
  • macOS: Use a LaunchAgent and reference a protected environment file rather than placing the token in a world-readable plist.
  • Windows: Use Task Scheduler with the correct Python executable and a working directory that contains the script.
  • CI runners: Avoid controlling a personal desktop Clash instance from an untrusted pipeline. Store secrets in the runner's secret manager and restrict network access.

Keep Recovery Predictable

The most important safety rule is to preserve the previous selection until the new node has passed testing. If no candidate responds, the script leaves the existing route untouched. If the selection request fails, it attempts to restore the previous member. This does not guarantee recovery when the controller itself has crashed, but it prevents a partial API response from being treated as a successful switch.

For more demanding workflows, add a second validation stage after switching. For example, request a small package index, resolve a known development host, or perform a lightweight HTTPS check through the selected route. A latency endpoint proves that the proxy responds; it does not prove that your registry, code host, or deployment service is reachable.

Operational Checklist

Review the log file, verify the group name, test with a harmless endpoint, confirm that DIRECT is excluded, and run the script manually several times before enabling a scheduler.

Common Problems and Adjustments

If every node fails, check whether the test URL is blocked or whether the controller uses a different API port. If the API returns an authorization error, regenerate the secret and ensure that no extra quotation marks are included in the environment variable. If a node tests successfully but your real task still fails, use a destination-specific test URL and consider protocol support, DNS behavior, UDP requirements, or provider-side rate limits.

Some clients expose a controller but restrict write operations, while others use a slightly different response format for delay testing. In that case, inspect the response with curl, compare it with the Mihomo API documentation for your installed version, and update the parsing logic rather than disabling authentication or error handling.

Automatic switching should complement, not replace, a well-designed Clash configuration. Keep rule policies clear, separate work traffic from general browsing when necessary, and use a dedicated group for automation. With authenticated API calls, measured node selection, bounded retries, and explicit recovery, long-running development jobs can continue with far less manual intervention.

Download Clash for Free – Get Started Now →