24 KiB
codewhale remote-setup - Tailscale-first design
Status: design / revision. This RFC revises the earlier cloud-first
remote-setup plan. Keep the accurate implementation work already present:
codewhale remote-setup exists today as a generate-only bundle wizard for
cloud plus chat bridge deployments, and --apply is still not implemented.
Goal
Give users a guided, education-forward way to reach a local-first CodeWhale runtime from another surface without accidentally publishing their agent.
Default posture:
- Local-first by default.
- Tailnet-private when remote.
- Public only when explicitly chosen.
The wizard should ask:
How do you want to reach CodeWhale?
and offer these paths, in this order:
- This machine only (localhost)
- Private devices with Tailscale (Recommended)
- Telegram bot
- Feishu/Lark bot
- Weixin personal bridge
- Public webhook / Funnel (Advanced)
The recommended remote answer is Tailscale Serve with the backend still bound
to 127.0.0.1. Tailscale supplies device identity and encrypted transport.
Tailscale Funnel is public internet exposure and must stay advanced.
Current implementation checkpoint
Verified against the codebase:
codewhale app-server --httpis the canonical HTTP/SSE runtime API entrypoint. It delegates to the matureserve --httpimplementation.codewhale app-server --mobileis real and serves the phone control page at/mobile.--host,--port,--workers,--auth-token,--insecure-no-auth, and repeatable--cors-originexist onapp-server --http/--mobile.--mobilewithout--hostbinds to0.0.0.0by design. Use--host 127.0.0.1when putting Tailscale in front of the runtime./healthand/v1/runtime/infoare public bootstrap/supervision endpoints./v1/*control routes require the runtime bearer token unless auth is explicitly disabled on a trusted loopback bind.codewhale doctor --jsonexists as the machine-readable local diagnostic.codewhale remote-setupexists, but today it is generate-only. Its current matrix is cloud target (lighthouse,azure,digitalocean) x bridge (feishu,telegram) x provider registry. It does not yet model localhost, Tailscale, Weixin, or Funnel as first-class choices.- Telegram and Feishu bridge validators exist as
npm run validate:config. Weixin currently hasnpm run check, but no validate-config script.
Accuracy note for the Tailscale recommendation: the requested setup uses
app-server --http, but the current runtime serves /mobile only in mobile
mode. This RFC keeps the target command shape for the recommended loopback
runtime, and documents the verified current-binary variant when the mobile page
is required:
# Runtime API only, verified:
codewhale app-server --http --host 127.0.0.1 --port 7878 --auth-token "$CODEWHALE_RUNTIME_TOKEN"
# Runtime API plus /mobile, verified:
codewhale app-server --mobile --host 127.0.0.1 --port 7878 --auth-token "$CODEWHALE_RUNTIME_TOKEN"
Common runtime base
Every path starts from the same local runtime trust boundary.
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --http \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
For the current binary, use --mobile --host 127.0.0.1 instead of --http if
the path needs the built-in /mobile page.
Doctor-style local validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
curl -fsS \
-H "Authorization: Bearer $CODEWHALE_RUNTIME_TOKEN" \
http://127.0.0.1:7878/v1/runtime/info
Runtime mental model:
- Exposed by CodeWhale: only the address it binds. The recommended bind is
127.0.0.1:7878. - Auth token:
CODEWHALE_RUNTIME_TOKEN, passed asAuthorization: Bearer ...by clients and bridges. LegacyDEEPSEEK_RUNTIME_TOKENremains a fallback. - Provider secrets: stay in runtime configuration, not in bridge env files.
- Bridge secrets: stay in transport-specific env files.
Guided flow
1. This machine only (localhost)
Use this when the TUI, SDK, browser, or local script runs on the same machine as CodeWhale.
Setup:
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --http \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
Env template:
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=<same value used to start app-server>
Validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
curl -fsS \
-H "Authorization: Bearer $CODEWHALE_RUNTIME_TOKEN" \
http://127.0.0.1:7878/v1/runtime/info
Trust boundary:
- Exposed: loopback only.
- Not exposed: LAN, tailnet, or public internet.
- Token used:
CODEWHALE_RUNTIME_TOKENfor control routes; local/healthand/v1/runtime/infoare public bootstrap endpoints.
2. Private devices with Tailscale (Recommended)
Use this to reach CodeWhale from your phone or laptop without opening a LAN or public port. Tailscale authenticates devices in your tailnet; CodeWhale still binds to localhost.
Target setup to feature in the wizard:
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --http \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
tailscale serve --bg --https=443 localhost:7878
Then open the Tailscale Serve URL from a phone or laptop in the same tailnet. For the current binary's mobile page, start CodeWhale with the verified mobile variant:
codewhale app-server --mobile \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
Then open (put the token in the URL fragment, not a query param — the
/mobile page reads it from location.hash, and a fragment is never sent to
the Tailscale serving layer or to any proxy log):
https://<machine>.<tailnet>.ts.net/mobile#token=<CODEWHALE_RUNTIME_TOKEN>
Env template:
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=<openssl-rand-hex-32>
TAILSCALE_SERVE_TARGET=localhost:7878
TAILSCALE_SERVE_URL=https://<machine>.<tailnet>.ts.net
Validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
curl -fsS https://<machine>.<tailnet>.ts.net/health
curl -fsS \
-H "Authorization: Bearer $CODEWHALE_RUNTIME_TOKEN" \
https://<machine>.<tailnet>.ts.net/v1/runtime/info
tailscale serve status
Trust boundary:
- Exposed: an HTTPS endpoint reachable by devices authorized in your tailnet.
- Not exposed: the raw CodeWhale listener; it stays on
127.0.0.1. - Token used: Tailscale identity gates network reachability; CodeWhale still
uses
CODEWHALE_RUNTIME_TOKENfor runtime control. - Caveat: Tailscale Serve is private to the tailnet. Tailscale Funnel is public internet exposure and belongs only in the advanced path below.
3. Telegram bot
Use this when a Telegram DM should control a local CodeWhale runtime. The bridge uses Telegram Bot API long polling, so it does not require a public webhook URL or inbound port.
Setup:
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --http \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
cd integrations/telegram-bridge
npm install --omit=dev
cp .env.example .env
$EDITOR .env
npm run validate:config -- \
--env .env \
--workspace-root "$PWD/../.." \
--check-filesystem
npm start
Env template:
TELEGRAM_BOT_TOKEN=replace-with-botfather-token
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=<same value used to start app-server>
CODEWHALE_WORKSPACE=/path/to/workspace
# Optional override; leave blank to inherit the runtime's configured provider/model.
CODEWHALE_MODEL=
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true # grants shell execution from the bridge; set false for text-only chat
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
TELEGRAM_CHAT_ALLOWLIST=
TELEGRAM_ALLOW_UNLISTED=false
TELEGRAM_ALLOW_GROUPS=false
First pairing:
# Temporarily in .env:
TELEGRAM_ALLOW_UNLISTED=true
DM the bot /status, copy the returned chat_id or user_id into
TELEGRAM_CHAT_ALLOWLIST, then set TELEGRAM_ALLOW_UNLISTED=false and restart
the bridge.
Validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
npm run validate:config -- \
--env .env \
--workspace-root "$PWD/../.." \
--check-filesystem
Trust boundary:
- Exposed: no inbound CodeWhale port. Telegram sees messages sent to the bot.
- Not exposed: CodeWhale remains on
127.0.0.1; provider keys stay in the runtime env, not the Telegram env. - Tokens used:
TELEGRAM_BOT_TOKENfor Telegram,CODEWHALE_RUNTIME_TOKENfor bridge-to-runtime calls, andTELEGRAM_CHAT_ALLOWLISTfor user/chat gating. - Caveat: direct messages are the intended MVP control surface. Group control is
off unless
TELEGRAM_ALLOW_GROUPS=true.
4. Feishu/Lark bot
Use this when a Feishu or Lark chat should control the local runtime. The bridge uses the Lark/Feishu long-connection SDK, so the first version does not need a public webhook URL.
Setup:
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --http \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
cd integrations/feishu-bridge
npm install --omit=dev
cp .env.example .env
$EDITOR .env
npm run validate:config -- \
--env .env \
--workspace-root "$PWD/../.." \
--check-filesystem
npm start
Env template:
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
FEISHU_APP_SECRET=replace-with-app-secret
FEISHU_DOMAIN=feishu # international Lark users: set to "lark"
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=<same value used to start app-server>
CODEWHALE_WORKSPACE=/path/to/workspace
# Optional override; leave blank to inherit the runtime's configured provider/model.
CODEWHALE_MODEL=
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true # grants shell execution from the bridge; set false for text-only chat
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
CODEWHALE_CHAT_ALLOWLIST=
CODEWHALE_ALLOW_UNLISTED=false
FEISHU_ALLOW_GROUPS=false
First pairing:
Temporarily set CODEWHALE_ALLOW_UNLISTED=true, message the app once, copy the
logged open id into CODEWHALE_CHAT_ALLOWLIST, then set
CODEWHALE_ALLOW_UNLISTED=false and restart the bridge.
Validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
npm run validate:config -- \
--env .env \
--workspace-root "$PWD/../.." \
--check-filesystem
Trust boundary:
- Exposed: no inbound CodeWhale port. Feishu/Lark sees messages sent to the app.
- Not exposed: CodeWhale remains on
127.0.0.1; provider keys stay in runtime config. - Tokens used:
FEISHU_APP_ID/FEISHU_APP_SECRETfor the platform,CODEWHALE_RUNTIME_TOKENfor bridge-to-runtime calls, andCODEWHALE_CHAT_ALLOWLISTfor chat gating. - Caveat: group control is off unless explicitly enabled.
5. Weixin personal bridge
Use this when a personal Weixin account should control the local runtime by QR login. This is not a public account webhook. The bridge initiates long polling and does not need a public port.
Setup:
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --http \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
cd integrations/weixin-bridge
npm install --omit=dev
cp .env.example .env
$EDITOR .env
npm run check
npm start
Env template:
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=<same value used to start app-server>
CODEWHALE_WORKSPACE=/path/to/workspace
# Optional override; leave blank to inherit the runtime's configured provider/model.
CODEWHALE_MODEL=
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true # grants shell execution from the bridge; set false for text-only chat
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
WEXIN_CHAT_ALLOWLIST=
WEXIN_ALLOW_UNLISTED=false
WEXIN_STATE_DIR=/var/lib/codewhale-weixin-bot-bridge
First pairing:
Set WEXIN_ALLOW_UNLISTED=true, start the bridge, scan the QR code, send
/status, copy the returned user_id into WEXIN_CHAT_ALLOWLIST, then set
WEXIN_ALLOW_UNLISTED=false and restart the bridge.
Validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
npm run check
Trust boundary:
- Exposed: no inbound CodeWhale port. The personal Weixin session and the bridge state directory become sensitive local state.
- Not exposed: CodeWhale remains on
127.0.0.1; provider keys stay in runtime config. - Tokens used: the scanned Weixin login/session state for platform access,
CODEWHALE_RUNTIME_TOKENfor bridge-to-runtime calls, andWEXIN_CHAT_ALLOWLISTfor user gating. - Caveat: this is a personal-account bridge. Treat the host and state directory like a logged-in phone session.
6. Public webhook / Funnel (Advanced)
Use this only when the user explicitly chooses public internet reachability, understands that the URL can be reached outside the tailnet, and has a reason that Tailscale Serve or long polling cannot satisfy.
Preferred advanced pattern:
CODEWHALE_RUNTIME_TOKEN="$(openssl rand -hex 32)"
export CODEWHALE_RUNTIME_TOKEN
codewhale app-server --mobile \
--host 127.0.0.1 \
--port 7878 \
--auth-token "$CODEWHALE_RUNTIME_TOKEN"
tailscale funnel --bg --https=443 localhost:7878
Env template:
CODEWHALE_RUNTIME_URL=https://<public-name>
CODEWHALE_RUNTIME_TOKEN=<openssl-rand-hex-32>
PUBLIC_EXPOSURE_ACK=true
Validation:
codewhale doctor --json
curl -fsS http://127.0.0.1:7878/health
curl -fsS https://<public-name>/health
curl -fsS \
-H "Authorization: Bearer $CODEWHALE_RUNTIME_TOKEN" \
https://<public-name>/v1/runtime/info
tailscale funnel status
Trust boundary:
- Exposed: a public HTTPS endpoint, not just your tailnet.
- Not exposed by CodeWhale directly: the backend still binds to
127.0.0.1, but the fronting layer makes selected routes reachable from the internet. - Token used:
CODEWHALE_RUNTIME_TOKENremains mandatory for control routes. - Caveat: public does not mean safe. Do not use
--insecure-no-auth, do not bind CodeWhale to0.0.0.0, and do not call this the default.
Cloud/VPS posture
Cloud/VPS is a placement choice, not a trust model. The old RFC's cloud work is still useful, but it should sit behind the same reachability choices:
- A VPS can run the runtime bound to
127.0.0.1. - Recommended remote access from personal devices is still Tailscale Serve.
- Bot bridges should use long polling / long connection where available, keeping the runtime localhost-only on the host.
- SSH tunnels remain acceptable for ad hoc validation:
ssh -L 7878:127.0.0.1:7878 <host>
Public inbound listeners, public webhooks, and Tailscale Funnel are advanced choices, not the default cloud path.
Prior art: Hermes Agent (reference only - do not copy)
Nous Research's Hermes Agent validates the table-driven part of this design. Use it for ideas; keep CodeWhale's style: Rust core, local runtime, zero-dep Node bridges where possible, and plain-text replies.
gateway/platform_registry.pymaps to ourBridgeSpec/ access-path registry: one row per platform, with setup hints, required env, validation, and adapter factory.gateway/pairing.pymaps to our allowlist / first-pairing flow.
Telegram hardening carried forward from the original RFC:
| Edge case | In Hermes | In our Telegram bridge |
|---|---|---|
| 409 polling conflict | _looks_like_polling_conflict |
done - poll loop backs off and warns |
429 retry_after |
rate-limit handling | done - telegramApi honors parameters.retry_after |
| Forum General topic id handling | send/typing split | done - omit message_thread_id when id is 1 on send |
| Stale reply anchor after restart | retry without anchor | sidestepped - no reply_to_message_id |
| Network/connect timeout retry | network error detection | partial - generic poll-loop backoff |
| Text batching / progress edit | progress-edit tests | deferred - plain periodic chunks |
| MarkdownV2 escaping | escaping helpers | deferred - plain text |
| Webhook mode | webhook adapter | out of default scope - long polling first |
Design principle: table-driven, like ProviderSpec
The provider registry is the model to preserve: adding a provider is one row. Apply the same idea to access paths, bridges, and cloud placements so the matrix grows by data.
AccessPath x Placement x BridgeSpec + ProviderSpec
---------- --------- ---------- ------------
localhost local none deepseek / openai / ...
tailscale local/vps none provider lives in runtime.env
telegram local/vps telegram bridge is pure transport
feishu local/vps feishu bridge is pure transport
weixin local/vps weixin bridge is pure transport
funnel local/vps optional explicit public exposure
Clean separation:
- Provider = runtime env. The runtime resolves provider/model/API key from
CODEWHALE_PROVIDER, provider key vars, and the provider registry. Bridges do not need provider keys. - Access path = reachability. Localhost, Tailscale Serve, chat long polling, and Funnel are separate choices with different trust boundaries.
- Bridge = transport. A chat bridge forwards allowed chat messages to
http://127.0.0.1:7878withCODEWHALE_RUNTIME_TOKEN. - Cloud = where it runs and where secrets live. It is not permission to open port 7878.
Proposed command surface
Current flags are verified for the generate-only cloud/bridge wizard:
| Flag | Current status |
|---|---|
| `--cloud <lighthouse | azure |
| `--bridge <telegram | feishu>` |
--provider <slug> |
verified, provider registry-backed |
--out <dir> |
verified |
--generate-only |
verified |
--apply |
verified flag, but not implemented |
--yes |
verified flag |
--non-interactive |
verified flag |
Proposed Tailscale-first revision:
| Flag | Meaning |
|---|---|
| `--access <localhost | tailscale |
| `--placement <local | vps |
| `--bridge <telegram | feishu |
--provider <slug> |
Provider slug; validated against the existing provider registry. |
--out <dir> |
Bundle output dir. |
--generate-only |
Emit commands/env/runbook, do not provision. Default. |
--apply |
Future cloud CLI provisioning, behind confirmation. Still not implemented. |
--yes |
Skip final confirmation gates where safe for CI/non-interactive use. |
--non-interactive |
Fail instead of prompting for missing required values. |
The first prompt should be the reachability question, not the cloud question. Tailscale should be visually marked as recommended.
Generated bundle
The current bundle model stays useful. Extend it so the generated runbook is access-path-first.
Files:
-
runtime.env- provider and runtime config:CODEWHALE_PROVIDER=openai OPENAI_API_KEY=replace-with-provider-key # Optional override; leave blank to inherit the runtime's configured provider/model.
CODEWHALE_MODEL= CODEWHALE_RUNTIME_TOKEN= CODEWHALE_RUNTIME_PORT=7878 CODEWHALE_RUNTIME_WORKERS=2 RUST_LOG=info
- `<bridge>.env` - transport only when a bridge is selected:
```env
CODEWHALE_RUNTIME_URL=http://127.0.0.1:7878
CODEWHALE_RUNTIME_TOKEN=<same random token>
CODEWHALE_WORKSPACE=/opt/whalebro
# Optional override; leave blank to inherit the runtime's configured provider/model.
CODEWHALE_MODEL=
CODEWHALE_MODE=agent
CODEWHALE_ALLOW_SHELL=true # grants shell execution from the bridge; set false for text-only chat
CODEWHALE_TRUST_MODE=false
CODEWHALE_AUTO_APPROVE=false
codewhale-runtime.service- optional
codewhale-<bridge>.service - optional cloud artifacts:
cloud-init.yaml,provision.sh,cnb.yml, or cloud-specific runbook steps RUNBOOK.mdwith:- exact setup commands
- env template
- doctor-style validation
- first-pairing steps for bridges
- trust-boundary summary
- explicit "public exposure acknowledged" section for Funnel/webhook modes
Auto-provision
Preserve the original safety model:
--generate-onlyis the default.--applyis explicit and is not implemented today.- Every command is rendered before execution.
- Secrets are not passed through shell history or argv.
- Cloud CLIs are placement helpers, not permission to open runtime ports.
Existing cloud target design remains accurate:
- Tencent Lighthouse: native plus systemd, env-file secrets, CNB-oriented plan.
- Azure VM: Docker image plus Key Vault, managed identity at boot.
- DigitalOcean Droplet: native plus systemd, env-file secrets,
doctlplan.
All cloud plans should bind CodeWhale to 127.0.0.1 and then layer one of the
reachability paths above.
Namespace migration: DEEPSEEK_* to CODEWHALE_*
Carry forward the convention already used in code: read CODEWHALE_X first,
fall back to DEEPSEEK_X where compatibility is needed.
Touch list from the original RFC remains valid:
- Bridges: read
CODEWHALE_X ?? DEEPSEEK_Xfor runtime URL/token, workspace, model, mode, shell/trust/approval flags, allowlists, and timeouts. Templates should emitCODEWHALE_*. - Deploy units: prefer
/etc/codewhale/*.env; keep legacy path reads only for compatibility where needed. .env.examplefiles andconfig.example.toml: lead withCODEWHALE_*, document legacy aliases.- Drop DeepSeek-shaped defaults in bridge templates except where DeepSeek is
explicitly the chosen provider. Provider choice belongs in
runtime.env.
Tests
Existing bundle tests should stay:
- Every cloud / bridge / provider triple renders.
- Runtime and bridge env files share the same
CODEWHALE_RUNTIME_TOKEN. - Env files lead with
CODEWHALE_*. - Generated runbooks are non-empty and list the provision plan.
- Provision plans are command data and are not executed in tests.
New tests for this revision:
- Every
AccessPathrow has setup commands, env template, validation commands, and trust-boundary copy. - Tailscale is the recommended remote path in prompt ordering.
- Funnel/webhook mode requires an explicit advanced/public acknowledgement.
/mobiledocs useapp-server --mobile --host 127.0.0.1for current binary behavior, or clearly mark any--httpplus/mobilepath as proposed.- Weixin can be documented before it is in the
remote-setupregistry, but the wizard must mark it proposed until aBridgeSpecrow and validation story exist.
Suggested sequencing
- Revise the RFC and runbook copy to be Tailscale-first.
- Add an access-path registry above the existing cloud/bridge/provider tables.
- Add localhost and Tailscale generate-only bundles.
- Add Weixin as a
BridgeSpecrow or explicitly hide it behind "proposed" in the wizard until registry and validation support land. - Rework cloud bundles so placement is second and reachability is first.
- Add Funnel/webhook only as an advanced path with explicit public-exposure acknowledgement.
- Implement
--applylast, after generate-only output is reviewed.
Command verification ledger
Verified against CodeWhale code/docs in this worktree:
codewhale app-server --http --host 127.0.0.1 --port 7878 --auth-token TOKENcodewhale app-server --mobile --host 127.0.0.1 --port 7878 --auth-token TOKENcodewhale doctor --jsoncurl /healthand authenticatedcurl /v1/runtime/infonpm run validate:configfor Telegram and Feishu bridgesnpm run checkfor the Weixin bridge- Existing
remote-setupgenerate-only flags listed above
Marked proposed or external:
codewhale remote-setup --access ...and access-path registry- first-class Tailscale, localhost, Weixin, and Funnel choices in the wizard
--applyexecution- Tailscale CLI commands (
tailscale serve ...,tailscale funnel ...) are external Tailscale commands. They are the intended RFC examples, but they are not CodeWhale CLI flags.