Expose local ports to the internet via HTTPS subdomains. Use when the user wants to create a tunnel, expose a local service, get a public URL for a local port, list active tunnels, or tear down a tunnel. Self-hosted ngrok alternative using AWS ALB + SSH relay.
How it works:
{subdomain}.tunnel.gnarlysoft.com to the bastion EC2 instance on an assigned portComponents:
The API has four endpoints: setup, create, list, and delete tunnels. </context>
<instructions>Credentials are stored in ${CLAUDE_SKILL_DIR}/.env and loaded via get-token.sh:
GNARLY_TUNNEL_API_URL=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_URL)
GNARLY_TUNNEL_API_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_KEY)
GNARLY_TUNNEL_BASTION_HOST=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_BASTION_HOST)
GNARLY_TUNNEL_SSH_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_SSH_KEY 2>/dev/null || echo "$HOME/.ssh/gnarly-tunnel-key")
If the .env file is missing or any required variable is empty, automatically run the setup flow (see Setup section below). Do NOT ask the user to manually create files or run shell commands — the skill handles everything.
SECURITY: Never display, echo, or expose the API key in chat output. Read tokens silently and use them only within command variables and headers.
The setup flow is fully automated. When the user runs /tunnel setup, OR when any other action fails because .env is missing:
Use AskUserQuestion to ask for the API URL and API key. The user gets these from their GnarlyTunnel deployment (CloudFormation outputs) or from the team wiki.
SSH_KEY_PATH="$HOME/.ssh/gnarly-tunnel-key"
if [ ! -f "$SSH_KEY_PATH" ]; then
ssh-keygen -t ed25519 -f "$SSH_KEY_PATH" -N "" -C "gnarly-tunnel" -q
echo "Generated SSH key at $SSH_KEY_PATH"
else
echo "SSH key already exists at $SSH_KEY_PATH"
fi
PUBLIC_KEY=$(cat "${SSH_KEY_PATH}.pub")
curl -s -X POST "${GNARLY_TUNNEL_API_URL}/setup" \
-H "x-api-key: ${GNARLY_TUNNEL_API_KEY}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg pk "$PUBLIC_KEY" '{"public_key": $pk}')" | python3 -m json.tool
The API registers the public key on the bastion server automatically via SSM. The response contains the bastion_host and base_domain.
Write the .env file with all values (use the bastion_host from the API response):
cat > ${CLAUDE_SKILL_DIR}/.env << ENVEOF
GNARLY_TUNNEL_API_URL=<api-url-from-step-1>
GNARLY_TUNNEL_API_KEY=<api-key-from-step-1>
GNARLY_TUNNEL_BASTION_HOST=<bastion-host-from-api-response>
GNARLY_TUNNEL_SSH_KEY=${SSH_KEY_PATH}
ENVEOF
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY_PATH}" tunnel@${GNARLY_TUNNEL_BASTION_HOST} "echo connected" 2>&1
Tell the user setup is complete and they can now create tunnels.
All API calls use this pattern:
GNARLY_TUNNEL_API_URL=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_URL)
GNARLY_TUNNEL_API_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_KEY)
curl -s -X <METHOD> "${GNARLY_TUNNEL_API_URL}/<path>" \
-H "x-api-key: ${GNARLY_TUNNEL_API_KEY}" \
-H "Content-Type: application/json" \
[-d '<json_body>']
Always load credentials via get-token.sh before each request. Always pipe responses through python3 -m json.tool for readable output.
GNARLY_TUNNEL_API_URL=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_URL)
GNARLY_TUNNEL_API_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_KEY)
GNARLY_TUNNEL_BASTION_HOST=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_BASTION_HOST)
GNARLY_TUNNEL_SSH_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_SSH_KEY 2>/dev/null || echo "$HOME/.ssh/gnarly-tunnel-key")
curl -s -X POST "${GNARLY_TUNNEL_API_URL}/tunnels" \
-H "x-api-key: ${GNARLY_TUNNEL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"subdomain": "<SUBDOMAIN>", "local_port": <PORT>}' | python3 -m json.tool
Parameters:
subdomain — lowercase alphanumeric with hyphens, 1-63 chars (e.g., my-app, feature-x, webhook-test)local_port — the local port to expose (e.g., 3000, 8080)Response includes:
url — the public HTTPS URL (e.g., https://my-app.tunnel.gnarlysoft.com)bastion_port — the assigned relay port on the bastionssh_command — the exact SSH command the user needs to runAfter creating the tunnel, tell the user to run the SSH command to activate it:
ssh -N -R <bastion_port>:localhost:<local_port> -i <ssh_key_path> tunnel@<bastion_host>
Or suggest running it in the background with ssh -f -N -R ....
The tunnel is only active while the SSH connection is open.
GNARLY_TUNNEL_API_URL=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_URL)
GNARLY_TUNNEL_API_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_KEY)
curl -s "${GNARLY_TUNNEL_API_URL}/tunnels" \
-H "x-api-key: ${GNARLY_TUNNEL_API_KEY}" | python3 -m json.tool
Returns all active tunnels with subdomain, URL, port, and health status.
GNARLY_TUNNEL_API_URL=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_URL)
GNARLY_TUNNEL_API_KEY=$(${CLAUDE_SKILL_DIR}/scripts/get-token.sh GNARLY_TUNNEL_API_KEY)
curl -s -X DELETE "${GNARLY_TUNNEL_API_URL}/tunnels/<SUBDOMAIN>" \
-H "x-api-key: ${GNARLY_TUNNEL_API_KEY}" | python3 -m json.tool
This removes the ALB listener rule and target group. Remind the user to kill their SSH tunnel too.
git branch --show-current 2>/dev/null | tr '/' '-' | tr '[:upper:]' '[:lower:]'.env is missing when the user tries any action, run the setup flow automatically instead of failing/tunnel setup