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. A machine paired with Skillhook Cloud needs no tunnel to receive webhooks: a hosted webhook URL on the cloud takes the delivery, asleep or awake, behind NAT or not, and the machine collects it over its outbound link and verifies the signature with its own secret (cloud.md, https://skillhook.dev/docs/hosted-urls?ref=skillhook-docs).
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. |