Clash API Node Switching: Advanced Automation Guide
Learn how to turn Clash into a self-healing proxy workflow. Test node health, select a responsive proxy group member, and trigger automated failover with practical Shell and Python examples suitable for developers and network engineers.
Clash can already switch between members of a proxy group, but its built-in health checks are not always enough for operational workflows. A desktop client may mark a node as available while an application still cannot reach its target, or a provider may return a node that responds slowly enough to cause repeated connection failures. By using the external controller API, a script can inspect the current proxy groups, test individual members, select the fastest acceptable node, and move traffic away from a failed endpoint without opening the GUI.
This approach is useful for developers running local services, network engineers managing a gateway, and anyone who needs repeatable failover instead of manually clicking through nodes. The examples below use the REST API exposed by Clash-compatible cores, especially mihomo. The same design also applies to clients such as Clash Verge Rev or other front ends that expose an external controller backed by mihomo.
The controller is an administration interface, not an ordinary proxy port. Bind it to 127.0.0.1 when the script runs on the same machine, set a non-empty secret, and never publish the controller port directly to the public Internet. A leaked controller secret can allow another person to change proxy selections, inspect runtime state, and alter the behaviour of the client.
API model and safe controller configuration
The controller normally listens on a separate HTTP address, commonly 127.0.0.1:9090. This port is independent from mixed-port, port, and socks-port. The proxy listener carries application traffic; the external controller receives management requests. Confusing these ports is one of the most common reasons an automation script reports “connection refused”.
A minimal mihomo configuration can look like this:
external-controller: 127.0.0.1:9090
secret: "replace-with-a-long-random-secret"
mixed-port: 7890
mode: rule
log-level: info
After editing the configuration, reload the profile or restart the core according to the client you use. Check that the controller is listening before writing the automation logic:
curl -sS \
-H 'Authorization: Bearer replace-with-a-long-random-secret' \
http://127.0.0.1:9090/version
A successful response contains a JSON object with the running version. If the request returns 401, the secret is missing or incorrect. If it returns 404, confirm the path and controller implementation. If it cannot connect at all, inspect the controller address, the selected profile, and whether the client has actually started its core.
| Endpoint | Method | Purpose | Typical response |
|---|---|---|---|
/version |
GET | Confirm that the controller is reachable | Core version and meta information |
/proxies |
GET | List proxies and proxy groups | Names, types, current selections, and members |
/proxies/{name} |
GET | Inspect one proxy or group | Group type, active member, and available members |
/proxies/{name}/delay |
GET | Measure a proxy against a URL | HTTP status and measured delay |
/proxies/{name} |
PUT | Select a member for a manual or select group | Usually an empty successful response |
Proxy and group names are part of the URL, so they must be URL-encoded. A group named Auto Select cannot safely be inserted into a path as a raw space. Use a URL encoder in Python, or let curl encode the path segment with --data-urlencode only where appropriate. For simple Shell scripts, jq can extract names, but it does not perform URL encoding by itself.
Choose an automation target
Do not begin by assuming that the first group in the configuration is the group you need. A configuration can contain selector groups, URL-test groups, fallback groups, relay groups, and load-balance groups. The API reports the group type and its members. A script should verify that the requested group exists and that the desired node is one of its current members before attempting a switch.
For most custom automation, a select group is the easiest target because the API can explicitly set its selected member. A fallback or url-test group may already perform automatic selection inside the core. In that situation, the external script should usually monitor the group or select a member only when it has a clear operational reason. Repeatedly overriding a built-in health-check group can create a fight between the script and the core.
Test nodes with realistic health checks
The delay endpoint tests a named proxy against a supplied URL. It does not simply measure the time needed to reach the proxy server itself. The result depends on the target URL, DNS behaviour, TLS negotiation, routing rules, and the node. Therefore, the test URL should represent the service that matters to your workflow. A lightweight HTTPS endpoint is normally better than a large web page or a service with aggressive rate limiting.
A typical request looks like this:
curl -sS --get \
-H 'Authorization: Bearer replace-with-a-long-random-secret' \
--data-urlencode 'url=https://www.gstatic.com/generate_204' \
--data-urlencode 'timeout=5000' \
'http://127.0.0.1:9090/proxies/Node%20A/delay'
The path segment Node%20A represents the encoded proxy name. The timeout is expressed in milliseconds in common Clash-compatible controller implementations. A successful response usually includes a numeric delay. A failed test may return an HTTP error or a JSON error message, depending on the core version and the reason for failure. Treat both transport errors and malformed responses as failed health checks.
One measurement is not enough to decide that a node is dead. Mobile networks, congested links, and busy servers can produce an occasional outlier. A more useful policy is to run two or three probes, discard failures, and calculate either the median or the average of the successful values. You can then require a minimum number of successful probes and a maximum acceptable delay.
| Policy input | Example | Why it matters |
|---|---|---|
| Probe URL | https://www.gstatic.com/generate_204 |
Should be small, stable, and relevant to the route |
| Per-request timeout | 5000 ms | Prevents one dead node from blocking the whole scan |
| Probe count | 3 attempts | Reduces decisions caused by one transient packet loss |
| Maximum delay | 1500 ms | Rejects nodes that technically respond but are unusable |
| Minimum success count | 2 of 3 | Separates a temporary outlier from a consistently failing node |
Use the same test URL for all candidates during one selection cycle. If every node fails, do not switch to the first result or clear the current selection automatically. The failure may be caused by a broken subscription, a local DNS problem, a blocked controller request, or an unreachable test service rather than by every node being offline.
Shell workflow for fast failover
The following Shell example reads the members of a named group, tests them one by one, and selects the lowest-delay member that passes the threshold. It requires curl and jq. The group name and secret are supplied through environment variables so that the secret does not become part of the script file or command history.
#!/usr/bin/env bash
set -Eeuo pipefail
API="${CLASH_API:-http://127.0.0.1:9090}"
SECRET="${CLASH_SECRET:?Set CLASH_SECRET first}"
GROUP="${CLASH_GROUP:-Auto Select}"
TEST_URL="${CLASH_TEST_URL:-https://www.gstatic.com/generate_204}"
TIMEOUT_MS="${CLASH_TIMEOUT_MS:-5000}"
MAX_DELAY="${CLASH_MAX_DELAY:-1500}"
auth=(-H "Authorization: Bearer ${SECRET}")
encoded_group="$(jq -rn --arg value "$GROUP" '$value|@uri')"
group_json="$(curl -fsS "${auth[@]}" \
"${API}/proxies/${encoded_group}")"
group_type="$(jq -r '.type // empty' <<< "$group_json")"
if [[ "$group_type" != "Selector" && "$group_type" != "URLTest" \
&& "$group_type" != "Fallback" ]]; then
printf 'Unsupported or missing group: %s\n' "$GROUP" >&2
exit 1
fi
mapfile -t members < <(jq -r '.all[]?' <<< "$group_json")
if ((${#members[@]} == 0)); then
echo "The group has no members" >&2
exit 1
fi
best_name=""
best_delay=""
for member in "${members[@]}"; do
encoded_member="$(jq -rn --arg value "$member" '$value|@uri')"
response="$(curl -fsS --get "${auth[@]}" \
--data-urlencode "url=${TEST_URL}" \
--data-urlencode "timeout=${TIMEOUT_MS}" \
"${API}/proxies/${encoded_member}/delay" 2>/dev/null || true)"
delay="$(jq -r '.delay // empty' <<< "$response" 2>/dev/null || true)"
if [[ "$delay" =~ ^[0-9]+$ ]] && ((delay <= MAX_DELAY)); then
printf '%-32s %s ms\n' "$member" "$delay"
if [[ -z "$best_delay" || "$delay" -lt "$best_delay" ]]; then
best_name="$member"
best_delay="$delay"
fi
else
printf '%-32s failed\n' "$member" >&2
fi
done
if [[ -z "$best_name" ]]; then
echo "No candidate passed the health check; keeping the current selection" >&2
exit 2
fi
payload="$(jq -nc --arg name "$best_name" '{name: $name}')"
curl -fsS -X PUT "${auth[@]}" \
-H 'Content-Type: application/json' \
-d "$payload" \
"${API}/proxies/${encoded_group}" >/dev/null
printf 'Selected %s at %s ms\n' "$best_name" "$best_delay"
Run it with a short-lived environment variable or a protected service configuration:
export CLASH_SECRET='replace-with-a-long-random-secret'
export CLASH_GROUP='Auto Select'
export CLASH_MAX_DELAY=1200
./select-node.sh
The script deliberately leaves the current group selection unchanged when all candidates fail. That is safer than switching to an arbitrary node, because a failed scan does not prove that another member is better. It also treats an unexpected group type as an error instead of sending a selection request blindly.
Avoid disruptive switching
Even a healthy node can be a poor immediate replacement if the current node is still working. Add hysteresis to prevent flapping: switch only when the current node fails two consecutive cycles, or when the best candidate is at least 300–500 milliseconds faster than the current member. Keep the last switch time in a small state file and enforce a cooldown such as five minutes.
For a scheduled job, a simple interval of 60 to 180 seconds is usually more reasonable than running a scan every few seconds. Each scan creates outbound requests, consumes provider or server resources, and may interrupt long-lived connections when the selected node changes. A failover decision should solve a real availability problem, not optimize every small variation in latency.
Python selection and failover engine
Python is more suitable when the workflow needs retries, structured logs, concurrent probes, or integration with a monitoring system. The example below uses only the standard library. It reads the group, tests members concurrently, rejects failed or slow results, and sends a PUT request only when a valid candidate is available.
#!/usr/bin/env python3
import concurrent.futures
import os
import statistics
import sys
import time
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
API = os.getenv("CLASH_API", "http://127.0.0.1:9090").rstrip("/")
SECRET = os.environ["CLASH_SECRET"]
GROUP = os.getenv("CLASH_GROUP", "Auto Select")
TEST_URL = os.getenv(
"CLASH_TEST_URL",
"https://www.gstatic.com/generate_204",
)
TIMEOUT_SECONDS = float(os.getenv("CLASH_TIMEOUT_SECONDS", "5"))
MAX_DELAY = int(os.getenv("CLASH_MAX_DELAY", "1500"))
PROBES = int(os.getenv("CLASH_PROBES", "2"))
HEADERS = {
"Authorization": f"Bearer {SECRET}",
"Accept": "application/json",
}
def request_json(method, path, body=None, timeout=TIMEOUT_SECONDS):
data = None
headers = dict(HEADERS)
if body is not None:
data = body.encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
API + path,
data=data,
headers=headers,
method=method,
)
with urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
return response.status, raw
def get_group():
path = "/proxies/" + quote(GROUP, safe="")
status, raw = request_json("GET", path)
if status != 200:
raise RuntimeError(f"group request returned HTTP {status}")
import json
data = json.loads(raw)
members = data.get("all", [])
if not members:
raise RuntimeError("the group has no available members")
return data, members
def probe(member):
path = "/proxies/" + quote(member, safe="") + "/delay"
values = []
for _ in range(PROBES):
query = urlencode({
"url": TEST_URL,
"timeout": str(int(TIMEOUT_SECONDS * 1000)),
})
try:
status, raw = request_json(
"GET",
path + "?" + query,
)
if status != 200:
continue
import json
delay = json.loads(raw).get("delay")
if isinstance(delay, int):
values.append(delay)
except (HTTPError, URLError, TimeoutError, ValueError):
continue
if not values:
return member, None, 0
median_delay = int(statistics.median(values))
return member, median_delay, len(values)
def select(member):
import json
path = "/proxies/" + quote(GROUP, safe="")
payload = json.dumps({"name": member})
status, _ = request_json("PUT", path, payload)
if status not in (200, 204):
raise RuntimeError(f"selection returned HTTP {status}")
def main():
group, members = get_group()
current = group.get("now")
group_type = group.get("type", "")
print(f"group={GROUP} type={group_type} current={current}")
with concurrent.futures.ThreadPoolExecutor(
max_workers=min(8, len(members))
) as pool:
results = list(pool.map(probe, members))
usable = [
result for result in results
if result[1] is not None and result[1] <= MAX_DELAY
]
for member, delay, successes in sorted(
results,
key=lambda item: item[1] if item[1] is not None else 999999,
):
value = "failed" if delay is None else f"{delay} ms"
print(f"{member}: {value}, successful probes={successes}")
if not usable:
print("No usable member; selection unchanged", file=sys.stderr)
return 2
best, best_delay, _ = min(usable, key=lambda item: item[1])
if current == best:
print("Current member already meets the policy")
return 0
if current:
current_result = next(
(item for item in results if item[0] == current),
None,
)
current_delay = current_result[1] if current_result else None
if current_delay is not None and best_delay >= current_delay - 300:
print("Improvement is below the switching margin")
return 0
select(best)
print(f"Selected {best} at {best_delay} ms")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyError:
print("CLASH_SECRET is not set", file=sys.stderr)
raise SystemExit(1)
except (HTTPError, URLError, TimeoutError, ValueError, RuntimeError) as exc:
print(f"Failover check failed: {exc}", file=sys.stderr)
raise SystemExit(1)
The Python version uses a median when more than one probe succeeds, which makes it less sensitive to one unusually slow response. It also applies a switching margin: if the current node is still healthy and the new candidate is not at least 300 milliseconds faster, the script keeps the current selection. Adjust that margin for the workload. Interactive browsing may tolerate a larger margin, while a latency-sensitive API service may use a smaller one.
During development, comment out the select(best) call and log the proposed decision first. Run the probe against a test group or a disposable profile, then confirm that group names, member names, URL encoding, and authentication behave as expected. Only enable automatic switching after you have observed several complete scan cycles.
Production scheduling and operational guardrails
A script that works once from an interactive terminal still needs operational controls before it becomes a self-healing workflow. Store the controller secret in an environment file readable only by the service account, a desktop credential store, or an operating-system secret manager. Do not commit it to a repository, place it in a URL, or print request headers in debug logs.
Use a lock so that two scheduled runs cannot scan and switch the same group simultaneously. On Linux, flock can wrap a cron command; a systemd service with a timer can also provide clearer logging and restart behaviour. On Windows, use Task Scheduler with a restricted account and ensure that only one instance is allowed to run. The exact scheduler is less important than preventing overlapping decisions.
# Example Linux cron entry: run every two minutes
*/2 * * * * flock -n /run/user/1000/clash-failover.lock \
/usr/local/bin/clash-failover.sh \
>> /home/user/.local/state/clash-failover.log 2>&1
Record at least the timestamp, group name, current member, candidate member, probe URL, delay values, and reason for the decision. Avoid logging subscription URLs or proxy credentials. When a switch occurs, write one clear event such as “selected node B after two failed checks of node A”. That information makes it possible to distinguish a real node outage from a script bug.
| Guardrail | Recommended behaviour | Failure-safe result |
|---|---|---|
| Authentication | Send a Bearer token on every request | Abort on 401; never retry without credentials |
| Timeout | Use 3–5 seconds per probe | Mark that member failed and continue scanning |
| Retry count | Use two or three probes per cycle | Do not switch on one transient failure |
| Cooldown | Wait several minutes after a switch | Prevent rapid node flapping |
| All candidates fail | Keep the existing selection | Preserve the last known route for diagnosis |
| Controller unavailable | Exit non-zero and log the error | Do not modify the configuration indirectly |
Remember that changing a proxy group affects new connections immediately, but existing TCP or QUIC sessions may continue using the previous path until they close. A browser tab can therefore appear unchanged for a short time after a successful switch. If an application maintains a connection pool, its own reconnect policy determines when the new node is actually used.
Finally, do not use node switching to hide an invalid configuration. If every node fails, inspect the subscription, DNS mode, TUN permissions, routing rules, and controller logs. If only one application is broken, verify whether it uses the system proxy or requires TUN capture. A good failover script narrows the outage and selects a verified alternative; it cannot repair an expired subscription, a blocked local port, or a rule that sends the target to DIRECT or REJECT.
The proxy-server-nameserver, unified-delay and tcp-concurrent fields in this article require the mihomo kernel. The original Clash kernel is no longer maintained, so if a field has no effect, confirm which kernel you're running and then work through the logs item by item.