Why the Clash External Controller Matters for Node Automation
A manually selected proxy node is a reasonable starting point, but it is a weak operating model for a connection whose quality changes throughout the day. Providers may rebalance traffic, upstream links may become congested, and a node that returned a fast result at breakfast can become unusable during the evening peak. Clicking a selector in Clash, Clash Verge, Clash Verge Rev, or another Mihomo-based client solves the immediate symptom, but it does not create a repeatable decision process. It also leaves no useful audit trail: nobody knows which node was tested, why it was selected, or whether the choice was still valid ten minutes later.
The Clash external controller API turns that manual action into an observable workflow. A compatible Clash or Mihomo core exposes an HTTP API, commonly bound to 127.0.0.1:9090, through which a local script can inspect proxy groups, read current selections, change a selector, and request health information. The API does not invent a better network path by itself. Instead, it gives your automation a controlled interface to the same policy groups that you already manage in YAML. That distinction is important: the YAML defines what is allowed, while the script decides when a permitted change should occur.
This separation also makes the design portable. A desktop user can run the workflow beside Clash Verge Rev, an operator can use a Mihomo service on a small Linux host, and a developer can invoke the same Python logic from a scheduled task. The graphical client remains useful for inspecting logs and validating the result, while the automation handles repetitive measurement. If your current configuration still mixes system proxy settings, browser extensions, and multiple VPN clients, establish one known baseline first. Otherwise, a successful API request may only prove that the group changed; it will not prove that applications are actually using the selected route.
Configure the External Controller Safely in YAML
Start by giving the automation a stable group name. A script should target a logical group such as AutoSelect, not a display label that changes every time a profile is regenerated. The group should contain only the nodes or nested groups that you have permission to use. A minimal structure might look like this:
mixed-port: 7890
external-controller: 127.0.0.1:9090
external-controller-cors:
allow-origins:
- http://127.0.0.1
secret: "replace-with-a-long-random-secret"
proxy-groups:
- name: AutoSelect
type: select
proxies:
- Node-A
- Node-B
- Node-C
- DIRECT
rules:
- MATCH,AutoSelect
The exact field names and supported options depend on the core shipped by your client, so verify them against the documentation for that version of Clash or Mihomo. The important ideas are consistent. external-controller determines where the API listens, secret authenticates requests, and the proxy group name becomes the stable resource that your script addresses. Binding the controller to 127.0.0.1 keeps it on the local machine. Avoid binding it to 0.0.0.0 merely because a tutorial does so; an unauthenticated or weakly protected controller can expose configuration details and allow anyone who reaches the port to redirect traffic.
Treat the controller secret like an API credential rather than a harmless setting. Use a long random value, keep it out of public repositories, and do not place it directly in a script that will be copied between workstations. On Linux, an environment variable or a root-readable configuration file is usually preferable. On Windows, store it in a protected user-level environment variable or a credential mechanism appropriate for your organization. On macOS, a restricted file or Keychain-backed workflow is safer than leaving the token in a shared shell history. The automation only needs local access, so there is rarely a good reason to publish the controller through a reverse proxy.
Before writing Python, test the controller with a harmless read request. The following command checks whether the endpoint responds and whether the authentication header is accepted:
curl -sS \
-H "Authorization: Bearer $CLASH_SECRET" \
http://127.0.0.1:9090/proxies/AutoSelect
A successful response normally contains the group type, the current selection, and a list of available members. A connection refusal usually means the controller is disabled, the port is different, or the GUI is running a core with another configuration. A 401 or 403 response points to a missing or incorrect secret. Do not “fix” an authentication problem by removing the secret in a production profile. First inspect the active configuration inside the client, because many desktop applications load a generated profile rather than the YAML file you edited manually.
Build Python Health Checks and Select a Suitable Node
A useful health check should measure the route that matters to your application, not just the controller itself. Testing a random public URL can tell you that one HTTPS request completed, but it may not reflect the latency or availability of the API, package registry, documentation host, or internal service that prompted the automation. Choose a small, stable endpoint that returns a predictable status code. Keep the request lightweight, set a short timeout, and avoid sending credentials or application data during a connectivity probe.
The controller API exposes different endpoint shapes depending on the core and version, so isolate those details in small functions. The following example demonstrates the general pattern: read the group, iterate over candidate names, test each candidate through a local proxy, and ask the controller to update the group only when the result is materially better. It is intentionally conservative and should be adapted after checking the API version used by your client.
import os
import time
import requests
CONTROLLER = "http://127.0.0.1:9090"
GROUP = "AutoSelect"
SECRET = os.environ["CLASH_SECRET"]
PROBE_URL = "https://example.com/health"
TIMEOUT = 6
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {SECRET}"})
def get_group():
response = session.get(
f"{CONTROLLER}/proxies/{GROUP}",
timeout=TIMEOUT
)
response.raise_for_status()
return response.json()
def switch_group(name):
response = session.put(
f"{CONTROLLER}/proxies/{GROUP}",
json={"name": name},
timeout=TIMEOUT
)
response.raise_for_status()
def measure(proxy_url):
started = time.perf_counter()
response = requests.get(
PROBE_URL,
proxies={"http": proxy_url, "https": proxy_url},
timeout=TIMEOUT
)
response.raise_for_status()
return (time.perf_counter() - started) * 1000
group = get_group()
candidates = [name for name in group.get("all", []) if name != "DIRECT"]
results = []
for candidate in candidates:
switch_group(candidate)
time.sleep(0.4)
try:
latency = measure("http://127.0.0.1:7890")
results.append((latency, candidate))
except requests.RequestException:
continue
if results:
best_latency, best_name = min(results)
if group.get("now") != best_name:
switch_group(best_name)
print(f"Selected {best_name}: {best_latency:.0f} ms")
This example exposes a subtle operational problem: changing a selector and immediately measuring it can produce a false result because existing TCP connections may still use the previous route. A short settling delay helps, but it is not a guarantee. For more reliable measurements, use a fresh HTTP session for every candidate, make several probes, and calculate a median rather than trusting one unusually fast response. A node that wins one sample by five milliseconds but loses every other sample is not necessarily better; it may simply have benefited from a warm connection or a temporary CDN edge.
Do not test every node on every run if the profile contains dozens of candidates. Sequential testing is easy to understand but can create a long outage window, because the active group changes repeatedly while the script works. A better design maintains a small candidate set, remembers recent results, and only retests nodes whose scores are stale. You can also separate measurement from activation: test candidates through a dedicated group, then switch the user-facing group once the winner is known. This prevents normal traffic from bouncing through every node during a scan.
Error handling deserves as much attention as the request itself. Distinguish a timeout, a refused connection, an invalid certificate, an HTTP error, and a controller authentication failure. A failed probe should mark a candidate unhealthy for the current run, not automatically delete it from the YAML profile. Providers may recover, and temporary upstream failures should not cause destructive configuration edits. Log the timestamp, candidate name, probe target, measured latency, status code, and final decision, but redact secrets, subscription URLs, authorization headers, and private response bodies.
Use Latency Rules Instead of Chasing the Fastest Number
The fastest observed node is not always the best production node. Latency, packet loss, sustained throughput, route stability, and geographic suitability can conflict. A node returning 90 milliseconds with intermittent failures may be worse than a stable 125-millisecond node. Your automation should therefore define acceptance rules before ranking candidates. For example, reject a node after two consecutive timeouts, reject results above 800 milliseconds, and require a minimum sample count before changing away from the current selection.
Hysteresis is one of the most valuable safeguards. Without it, two nodes with nearly identical measurements can cause the selector to oscillate every time the scheduler runs. Set a switching margin, such as requiring the new candidate to be at least 15 percent faster or 50 milliseconds better than the current node. Also impose a minimum hold time so that a newly selected node remains active for a reasonable period. These rules turn noisy measurements into a stable policy:
- Keep the current node when it remains below the failure threshold.
- Consider a replacement only after several independent probe samples.
- Switch only when the candidate beats the current score by a meaningful margin.
- Apply a cooldown after every switch to prevent rapid oscillation.
- Use a safe fallback when every candidate fails.
A practical scoring model can combine latency and reliability. Suppose each node receives a median latency score, a packet-loss penalty, and a recent-failure penalty. The script can rank the result instead of sorting by raw milliseconds. Keep the formula simple enough to explain during an incident. Operators should be able to answer why a node won without reverse-engineering a machine-learning system. In many environments, a rule such as “no recent failures, median latency under 250 milliseconds, and a 20 percent improvement before switching” is more useful than an elaborate opaque score.
Consider whether your probe target belongs to the same traffic class as the workload. If the script is optimizing access to a code repository, use a permitted repository health endpoint or a small metadata request rather than a random video site. If the target application uses WebSocket connections or long-lived streams, supplement a quick HTTPS probe with a controlled session test. Conversely, do not use a heavyweight download as a recurring health check; it wastes bandwidth and can trigger provider limits. The goal is to measure suitability, not to create another source of congestion.
Rules should also account for manual overrides. An operator may deliberately choose a specific node for debugging, a region-sensitive service, or a maintenance window. Provide an environment variable, lock file, or separate “manual” group that temporarily disables automatic switching. Display the reason in logs and make the override expire automatically if appropriate. Silent automation that keeps undoing a human decision is difficult to trust, while automation that clearly reports “automatic selection paused” is easier to operate.
Schedule the Workflow Without Creating a New Failure Point
Once the script behaves correctly in the foreground, schedule it conservatively. On Linux, a systemd timer provides clearer logging and dependency control than an opaque cron entry. On Windows, Task Scheduler can run the script when the user logs on or at a fixed interval, provided the task has access to the same environment variables and local profile as the Clash client. On macOS, launchd is generally more predictable than leaving a terminal process running. Whatever scheduler you choose, make the interval longer than the probe settling time and include a timeout around the entire job.
The scheduler must know whether the Clash process is ready. A laptop may start the task before Clash Verge has loaded its profile, while a server may restart Mihomo after an update. Add a preflight check that confirms the controller endpoint is reachable, the target group exists, and at least one candidate is available. If any prerequisite fails, exit without changing the active selection. A script that treats “controller unavailable” as “switch to DIRECT” can unintentionally expose traffic or bypass a policy boundary.
Use a lock to prevent overlapping runs. If one scan takes longer than expected and the next scheduled invocation starts, the two processes may alternate selections and corrupt each other’s logs. A lock file, operating-system mutex, or scheduler-native “do not start a new instance” option prevents this race. Write logs to a rotating location, use UTC or an explicit timezone consistently, and record the configuration or profile version that was active when the decision was made.
Security should remain local by default. Restrict file permissions on the script, its environment file, and its logs. Never print the bearer token during exception handling, and be cautious with libraries that include complete request headers in debug output. If you must manage several machines, prefer a secured configuration-management channel over exposing every controller port to a shared network. A controller API is an administrative surface, not a general-purpose remote service. Network reachability alone should never be treated as authentication.
Finally, test failure deliberately. Stop the Clash core, change the controller port, make the probe target return an error, disconnect the network, and simulate a profile refresh that renames a node. Confirm that the script exits safely, preserves the last valid selection, and produces a useful alert. Test recovery as well: when the preferred node becomes healthy again, the workflow should return to it only if the margin and cooldown rules allow that move. This kind of controlled chaos is far more valuable than observing a single successful run on a quiet afternoon.
Manual switching in lightweight tray clients is convenient but provides no measurement history, while generic “auto” features in some proxy managers may hide their probe target, threshold, or fallback behavior. A custom controller workflow gives developers and operators explicit YAML groups, inspectable Python checks, auditable thresholds, and scheduler-level safety controls. Clash V.CORE is a practical fit for this model because it keeps policy groups visible while giving your automation a consistent local runtime to manage; if you want to reproduce the configuration on a supported desktop or server, visit the download page and choose the build that matches your platform.
// Editor's Pick
Clash V.CORE for Reliable Node Automation
Build a safer external-controller workflow with visible groups, predictable local APIs, and the routing controls your health-check scripts need.
- Stable proxy groups for API switching
- Local controller workflow support
- Clear YAML policy boundaries
- Compatible with scheduled health checks
- Easy inspection through client logs