skillhook listens on 127.0.0.1:8787 and never terminates TLS itself. A tunnel on the same machine gives it a stable HTTPS URL that webhook senders can reach. The default is Tailscale Funnel: free, no domain to buy, and the configuration survives reboots. Tailscale Serve (tailnet only), Cloudflare Tunnel and ngrok also work.
Related: security.md (why the server stays on loopback), operations.md (running the server as a service).
Prerequisites: Tailscale installed and signed in on the machine (tailscale status reports Running); HTTPS certificates enabled for the tailnet; the funnel node attribute granted in the tailnet policy. The last two are a one-time approval in the admin console, and the CLI prints the link.
skillhook expose tailscaleWhat it does:
- Finds the
tailscalebinary (onPATH, then/usr/local/bin/tailscale,/opt/homebrew/bin/tailscale,/Applications/Tailscale.app/Contents/MacOS/Tailscale,/usr/bin/tailscale) and checks thattailscale status --jsonreportsBackendState: Running. - Runs
tailscale funnel --bg --yes <port>, where<port>isportfromskillhook.json(--port Noverrides it).--bgstores the mapping inside tailscaled, so it persists across reboots and does not depend on skillhook running. - Reads
tailscale serve status --jsonback, derives the URL (https://<machine>.<tailnet>.ts.net) and writes it topublic_urlinskillhook.json. - If a local server is running, polls
<url>/healthfor up to 60 seconds and reportsverified, or a hint to check again (a node's first TLS certificate can take a while). - Prints the webhook URL of every skill.
✓ Tailscale Funnel (public internet) → http://127.0.0.1:8787
https://jonathans-mbp.tail1234.ts.net
Persistent: survives reboots (stored by tailscaled). Turn off with: skillhook expose off
✓ verified: https://jonathans-mbp.tail1234.ts.net/health answered after 4s
Webhook URLs:
hello https://jonathans-mbp.tail1234.ts.net/hooks/hello
The server stays bound to 127.0.0.1; only Tailscale's TLS proxy reaches it.
The hostname is your Tailscale node name; it changes only if you rename the machine or move tailnets. Funnel accepts connections on ports 443, 8443 and 10000; skillhook uses 443, so URLs have no port suffix.
First-time approval: when Funnel is not yet allowed for the node, the command exits 1 and prints
Funnel needs a one-time approval in the Tailscale admin console: https://login.tailscale.com/f/funnel?node=…
Approve it, then run this command again.
Open the link, approve, and re-run skillhook expose tailscale (--json output carries the link as approval_url).
Status and teardown:
skillhook expose statusshows the Tailscale state, every serve/funnel mapping on the node (marking the one that targets skillhook's port) and the configured public_url.
skillhook expose offruns tailscale funnel --https=443 off and clears public_url.
Same mechanics, but the URL answers only for devices on your tailnet: right for skills triggered by your own scripts, iOS Shortcuts or internal services, and for reaching the admin API from another machine.
skillhook expose tailscale --serveskillhook expose offPair it with an IP allow-list so that even a leaked bearer token is useless from outside the tailnet (Tailscale forwards the client's tailnet address in X-Forwarded-For):
skillhook:
auth:
type: bearer
allow_ips: ["100.64.0.0/10"]Free; needs a domain on Cloudflare. skillhook expose cloudflare prints the recipe:
brew install cloudflaredcloudflared tunnel logincloudflared tunnel create skillhookcloudflared tunnel route dns skillhook hooks.example.comcloudflared tunnel run --url http://127.0.0.1:8787 skillhook # or: cloudflared service installskillhook config set public_url https://hooks.example.comThe free tier includes one static domain. skillhook expose ngrok prints the recipe:
brew install ngrok && ngrok config add-authtoken <token># claim a static domain at https://dashboard.ngrok.com/domains
ngrok http --url=<your-name>.ngrok-free.app 8787skillhook config set public_url https://<your-name>.ngrok-free.apppublic_url in skillhook.json is informational: skillhook url, skills list, send --public, doctor and the MCP tools use it to print and test webhook URLs, and serve logs it at start. expose tailscale sets it and expose off clears it; for other tunnels set it yourself with skillhook config set public_url https://… (and skillhook config unset public_url to remove it).
URL resolution order used by url, skills list and the MCP tools: public_url; an active Tailscale mapping that targets the configured port; the running server's local address; the configured host:port. send defaults to the local server; --public forces the first two.
With trust_proxy: true (default), when the TCP peer is loopback and the request carries X-Forwarded-For (or X-Real-IP / CF-Connecting-IP), the first address becomes the client IP used for rate limits, allow_ips, {{source_ip}} and job records. Tailscale, cloudflared and ngrok all set one of these. A request carrying any proxy header is treated as "via proxy" and cannot use the token-less local admin access (security.md). Set trust_proxy: false only if something other than a local proxy connects to the port directly.
skillhook urlcurl -i https://<node>.ts.net/hooks/hello # GET → 200 "skillhook: POST your webhook to this URL."skillhook send hello --public --wait 60skillhook doctor # includes a "public url" check that fetches <public_url>/health| Symptom | Cause and fix |
|---|---|
tailscale CLI not found |
Install Tailscale (https://tailscale.com/download) and sign in. skillhook also looks inside /Applications/Tailscale.app. |
Tailscale is NeedsLogin / Stopped / not running |
Open the Tailscale app and sign in; tailscale status must say Running. |
Funnel needs a one-time approval in the Tailscale admin console |
Open the printed link, enable Funnel for the node (this also enables HTTPS certificates when needed), re-run the command. |
| Command succeeded, but the URL times out or fails TLS | The first certificate for a node can take up to a minute. skillhook expose status, then retry; send prints the same hint. |
doctor says port 8787 not exposed after you changed port |
The mapping targets a fixed local port. Re-run skillhook expose tailscale. |
| Works from the tailnet, not from the internet | Serve is enabled instead of Funnel: skillhook expose off, then skillhook expose tailscale (without --serve). |
Admin routes answer 401 through the tunnel |
Expected. Send Authorization: Bearer $SKILLHOOK_ADMIN_TOKEN. |
expose off says it could not disable |
Pass --serve if you exposed with --serve; check tailscale serve status. |