diff --git a/.github/fmt/entrypoint.sh b/.github/fmt/entrypoint.sh
index ca3741cd3ef36ff2bd2551a4d3e257a4bc4e87c0..b62b94e428a678683ee4c0202b101f246ebdd874 100755
--- a/.github/fmt/entrypoint.sh
+++ b/.github/fmt/entrypoint.sh
@@ -2,25 +2,17 @@
 
 source .github/lib.sh
 
-if [[ $(gofmt -l -s .) != "" ]]; then
-	echo "files are not formatted correctly"
-	echo "please run:"
-	echo "gofmt -w -s ."
-	exit 1
-fi
+gofmt -w -s .
+go run go.coder.com/go-tools/cmd/goimports -w "-local=$(go list -m)" .
+go run mvdan.cc/sh/cmd/shfmt -w -s -sr .
 
-out=$(go run golang.org/x/tools/cmd/goimports -l -local=nhooyr.io/ws .)
-if [[ $out != "" ]]; then
-	echo "imports are not formatted correctly"
-	echo "please run:"
-	echo "goimports -w -local=nhooyr.io/ws ."
-	exit 1
-fi
-
-out=$(go run mvdan.cc/sh/cmd/shfmt -l -s -sr .)
-if [[ $out != "" ]]; then
-	echo "shell scripts are not formatted correctly"
+if [[ $CI ]] && unstaged_files; then
+	set +x
+	echo
+	echo "files are not formatted correctly"
 	echo "please run:"
-	echo "shfmt -w -s -sr ."
+	echo "./test.sh"
+	echo
+	git status
 	exit 1
 fi
diff --git a/.github/lib.sh b/.github/lib.sh
old mode 100755
new mode 100644
index 39030e995531cfba66b08b836ed659f3e9446b7a..640f3a03c547e09d0aac65d4d1e566a693a8f2ec
--- a/.github/lib.sh
+++ b/.github/lib.sh
@@ -3,4 +3,17 @@
 set -euxo pipefail
 
 export GO111MODULE=on
-export GOFLAGS=-mod=readonly
+export PAGER=cat
+
+# shellcheck disable=SC2034
+# CI is used by the scripts that source this file.
+CI=${GITHUB_ACTION-}
+
+if [[ $CI ]]; then
+	export GOFLAGS=-mod=readonly
+fi
+
+function unstaged_files() {
+	[[ $(git ls-files --other --modified --exclude-standard) != "" ]]
+}
+
diff --git a/.github/test/entrypoint.sh b/.github/test/entrypoint.sh
index cf4b14d08ad4c718163f2856276fe07731a52986..db59e6692baf450d631453e9999db05a3ddc9ad8 100755
--- a/.github/test/entrypoint.sh
+++ b/.github/test/entrypoint.sh
@@ -2,36 +2,38 @@
 
 source .github/lib.sh
 
-function gomod_help() {
+# Unfortunately, this is the only way to ensure go.mod and go.sum are correct.
+# See https://github.com/golang/go/issues/27005
+go list ./... > /dev/null
+go mod tidy
+
+go install golang.org/x/tools/cmd/stringer
+go generate ./...
+
+if [[ $CI ]] && unstaged_files; then
+	set +x
 	echo
-	echo "you may need to update go.mod/go.sum via:"
-	echo "go list all > /dev/null"
-	echo "go mod tidy"
+	echo "generated code needs an update"
+	echo "please run:"
+	echo "./test.sh"
 	echo
-	echo "or git add files to staging"
+	git status
 	exit 1
-}
-
-go list ./... > /dev/null || gomod_help
-go mod tidy
-
-# Until https://github.com/golang/go/issues/27005 the previous command can actually modify go.sum so we need to ensure its not changed.
-if [[ $(git diff --name-only) != "" ]]; then
-	git diff
-	gomod_help
 fi
 
-mapfile -t scripts <<< "$(find . -type f -name "*.sh")"
-shellcheck "${scripts[@]}"
+(
+	shopt -s globstar nullglob dotglob
+	shellcheck ./**/*.sh
+)
 
 go vet -composites=false ./...
 
-go test -race -v -coverprofile=coverage.out -vet=off ./...
+COVERAGE_PROFILE=$(mktemp)
+go test -race -v "-coverprofile=${COVERAGE_PROFILE}" -vet=off ./...
+go tool cover "-func=${COVERAGE_PROFILE}"
 
-if [[ -z ${GITHUB_ACTION-} ]]; then
-	go tool cover -html=coverage.out
-else
+if [[ $CI ]]; then
 	bash <(curl -s https://codecov.io/bash)
+else
+	go tool cover "-html=${COVERAGE_PROFILE}" -o=coverage.html
 fi
-
-rm coverage.out
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..4383ca89cbe0f9a2aa8a3f1637391bda490d6548
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+coverage.html
diff --git a/accept.go b/accept.go
new file mode 100644
index 0000000000000000000000000000000000000000..fb227eb3ddf2541e02024289fc4dbe068937c574
--- /dev/null
+++ b/accept.go
@@ -0,0 +1,37 @@
+package ws
+
+import (
+	"fmt"
+	"net/http"
+)
+
+// AcceptOption is an option that can be passed to Accept.
+type AcceptOption interface {
+	acceptOption()
+	fmt.Stringer
+}
+
+// AcceptSubprotocols list the subprotocols that Accept will negotiate with a client.
+// The first protocol that a client supports will be negotiated.
+// Pass "" as a subprotocol if you would like to allow the default protocol.
+func AcceptSubprotocols(subprotocols ...string) AcceptOption {
+	panic("TODO")
+}
+
+// AcceptOrigins lists the origins that Accept will accept.
+// Accept will always accept r.Host as the origin so you do not need to
+// specify that with this option.
+// Use this option with caution to avoid exposing your WebSocket
+// server to a CSRF attack.
+// See https://stackoverflow.com/a/37837709/4283659
+func AcceptOrigins(origins ...string) AcceptOption {
+	panic("TODO")
+}
+
+// Accept accepts a WebSocket handshake from a client and upgrades the
+// the connection to WebSocket.
+// Accept will reject the handshake if the Origin is not the same as the Host unless
+// InsecureAcceptOrigin is passed.
+func Accept(w http.ResponseWriter, r *http.Request, opts ...AcceptOption) (*Conn, error) {
+	panic("TODO")
+}
diff --git a/dataframe.go b/dataframe.go
new file mode 100644
index 0000000000000000000000000000000000000000..251a0fa579636146339b4dd860ff363bf2aa01bc
--- /dev/null
+++ b/dataframe.go
@@ -0,0 +1,14 @@
+package ws
+
+import (
+	"nhooyr.io/ws/wscore"
+)
+
+// DataType represents the Opcode of a WebSocket data frame.
+//go:generate stringer -type=DataType
+type DataType int
+
+const (
+	Text   DataType = DataType(wscore.OpText)
+	Binary DataType = DataType(wscore.OpBinary)
+)
diff --git a/dataframe_string.go b/dataframe_string.go
new file mode 100644
index 0000000000000000000000000000000000000000..2f862722171491172dacd04ea035d51d69b87d1f
--- /dev/null
+++ b/dataframe_string.go
@@ -0,0 +1,17 @@
+// Code generated by "stringer -type=DataType"; DO NOT EDIT.
+
+package ws
+
+import "strconv"
+
+const _DataFrame_name = "TextBinary"
+
+var _DataFrame_index = [...]uint8{0, 4, 10}
+
+func (i DataType) String() string {
+	i -= 1
+	if i < 0 || i >= DataType(len(_DataFrame_index)-1) {
+		return "DataType(" + strconv.FormatInt(int64(i+1), 10) + ")"
+	}
+	return _DataFrame_name[_DataFrame_index[i]:_DataFrame_index[i+1]]
+}
diff --git a/dial.go b/dial.go
new file mode 100644
index 0000000000000000000000000000000000000000..119dbff92b1ef04856ed33f1dfa32bbb5c84815e
--- /dev/null
+++ b/dial.go
@@ -0,0 +1,41 @@
+package ws
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"net/http"
+)
+
+// DialOption represents a dial option that can be passed to Dial.
+type DialOption interface {
+	dialOption()
+	fmt.Stringer
+}
+
+// DialHTTPClient is the http client used for the handshake.
+// Its Transport must use HTTP/1.1 and must return writable bodies
+// for WebSocket handshakes.
+// http.Transport does this correctly.
+func DialHTTPClient(h *http.Client) DialOption {
+	panic("TODO")
+}
+
+// DialHeader are the HTTP headers included in the handshake request.
+func DialHeader(h http.Header) DialOption {
+	panic("TODO")
+}
+
+// DialSubprotocols accepts a slice of protcols to include in the Sec-WebSocket-Protocol header.
+func DialSubprotocols(subprotocols ...string) DialOption {
+	panic("TODO")
+}
+
+// We use this key for all client requests as the Sec-WebSocket-Key header is useless.
+// See https://stackoverflow.com/a/37074398/4283659.
+var secWebSocketKey = base64.StdEncoding.EncodeToString(make([]byte, 16))
+
+// Dial performs a websocket handshake on the given url with the given options.
+func Dial(ctx context.Context, u string, opts ...DialOption) (*Conn, *http.Response, error) {
+	panic("TODO")
+}
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000000000000000000000000000000000000..d746de69f4e5a50ae89268b35a73be1d1d9c1ab6
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,2 @@
+// Package ws implements the WebSocket protocol defined in RFC 6455.
+package ws
diff --git a/example_test.go b/example_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..36b3cbaaa02539a83601f9f4adeffe99136f75b7
--- /dev/null
+++ b/example_test.go
@@ -0,0 +1,130 @@
+package ws_test
+
+import (
+	"context"
+	"io"
+	"log"
+	"net/http"
+	"time"
+
+	"golang.org/x/time/rate"
+	"nhooyr.io/ws"
+	"nhooyr.io/ws/wsjson"
+)
+
+func ExampleEcho() {
+	fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		c, err := ws.Accept(w, r,
+			ws.AcceptSubprotocols("echo"),
+		)
+		if err != nil {
+			log.Printf("server handshake failed: %v", err)
+			return
+		}
+		defer c.Close(ws.StatusInternalError, "")
+
+		ctx := context.Background()
+
+		echo := func() error {
+			ctx, cancel := context.WithTimeout(ctx, time.Minute)
+			defer cancel()
+
+			typ, r, err := c.ReadMessage(ctx)
+			if err != nil {
+				return err
+			}
+
+			ctx, cancel = context.WithTimeout(ctx, time.Second*10)
+			defer cancel()
+
+			r.SetContext(ctx)
+			r.Limit(32768)
+
+			w := c.MessageWriter(ctx, typ)
+			_, err = io.Copy(w, r)
+			if err != nil {
+				return err
+			}
+
+			err = w.Close()
+			if err != nil {
+				return err
+			}
+
+			return nil
+		}
+
+		l := rate.NewLimiter(rate.Every(time.Millisecond*100), 10)
+		for l.Allow() {
+			err := echo()
+			if err != nil {
+				log.Printf("failed to read message: %v", err)
+				return
+			}
+		}
+	})
+	// For production deployments, use a net/http.Server configured
+	// with the appropriate timeouts.
+	err := http.ListenAndServe("localhost:8080", fn)
+	if err != nil {
+		log.Fatalf("failed to listen and serve: %v", err)
+	}
+}
+
+func ExampleAccept() {
+	fn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		c, err := ws.Accept(w, r,
+			ws.AcceptSubprotocols("echo"),
+		)
+		if err != nil {
+			log.Printf("server handshake failed: %v", err)
+			return
+		}
+		defer c.Close(ws.StatusInternalError, "")
+
+		type myJsonStruct struct {
+			MyField string `json:"my_field"`
+		}
+		err = wsjson.Write(c, myJsonStruct{
+			MyField: "foo",
+		})
+		if err != nil {
+			log.Printf("failed to write json struct: %v", err)
+			return
+		}
+
+		c.Close(ws.StatusNormalClosure, "")
+	})
+	// For production deployments, use a net/http.Server configured
+	// with the appropriate timeouts.
+	err := http.ListenAndServe("localhost:8080", fn)
+	if err != nil {
+		log.Fatalf("failed to listen and serve: %v", err)
+	}
+}
+
+func ExampleDial() {
+	ctx := context.Background()
+	ctx, cancel := context.WithTimeout(ctx, time.Minute)
+	defer cancel()
+
+	c, _, err := ws.Dial(ctx, "ws://localhost:8080")
+	if err != nil {
+		log.Fatalf("failed to ws dial: %v", err)
+		return
+	}
+	defer c.Close(ws.StatusInternalError, "")
+
+	type myJsonStruct struct {
+		MyField string `json:"my_field"`
+	}
+	err = wsjson.Write(c, myJsonStruct{
+		MyField: "foo",
+	})
+	if err != nil {
+		log.Fatalf("failed to write json struct: %v", err)
+		return
+	}
+
+	c.Close(ws.StatusNormalClosure, "")
+}
diff --git a/go.mod b/go.mod
index a049474e5fb3a34153a235a01ad17b6b3fdceaae..e5a34d28fce93902986d728a422a29bd8bbed89f 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,11 @@ module nhooyr.io/ws
 go 1.12
 
 require (
+	github.com/golang/protobuf v1.3.1
 	github.com/kr/pretty v0.1.0 // indirect
+	go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16
+	golang.org/x/net v0.0.0-20190311183353-d8887717615a
+	golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect
 	golang.org/x/tools v0.0.0-20190315191501-e6df0c1bb376
 	mvdan.cc/sh v2.6.4+incompatible
 )
diff --git a/go.sum b/go.sum
index 0690b06a16c2e22f5028db59f7818781af04490b..40b640343df0734afb3bb28c6a6c21f05a7a03c8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,13 +1,20 @@
+github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16 h1:3gGa1bM0nG7Ruhu5b7wKnoOOwAD/fJ8iyyAcpOzDG3A=
+go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20190315191501-e6df0c1bb376 h1:GfNg/J4IAJguoN+DWPTZ54ElycoBxtQkpxhrbA5edVA=
 golang.org/x/tools v0.0.0-20190315191501-e6df0c1bb376/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
 mvdan.cc/sh v2.6.4+incompatible h1:eD6tDeh0pw+/TOTI1BBEryZ02rD2nMcFsgcvde7jffM=
diff --git a/statuscode.go b/statuscode.go
new file mode 100644
index 0000000000000000000000000000000000000000..5ceb24ac60fddf62bc013ced4aee7f542e752b26
--- /dev/null
+++ b/statuscode.go
@@ -0,0 +1,69 @@
+package ws
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"math/bits"
+)
+
+// StatusCode represents a WebSocket status code.
+//go:generate stringer -type=StatusCode
+type StatusCode int
+
+// These codes were retrieved from:
+// https://www.iana.org/assignments/websocket/websocket.xhtml#close-code-number
+const (
+	StatusNormalClosure StatusCode = 1000 + iota
+	StatusGoingAway
+	StatusProtocolError
+	StatusUnsupportedData
+	// 1004 is reserved.
+	StatusNoStatusRcvd StatusCode = 1005 + iota
+	StatusAbnormalClosure
+	StatusInvalidFramePayloadData
+	StatusPolicyViolation
+	StatusMessageTooBig
+	StatusMandatoryExtension
+	StatusInternalError
+	StatusServiceRestart
+	StatusTryAgainLater
+	StatusBadGateway
+	StatusTLSHandshake
+)
+
+// CloseError represents an error from a WebSocket close frame.
+// Methods on the Conn will only return this for a non normal close code.
+type CloseError struct {
+	Code   StatusCode
+	Reason string
+}
+
+func (e CloseError) Error() string {
+	return fmt.Sprintf("WebSocket closed with status = %v and reason = %q", e.Code, e.Reason)
+}
+
+func parseClosePayload(p []byte) (code StatusCode, reason []byte, err error) {
+	if len(p) < 2 {
+		return 0, nil, fmt.Errorf("close payload too small, cannot even contain the 2 byte status code")
+	}
+
+	code = StatusCode(binary.BigEndian.Uint16(p))
+	reason = p[2:]
+
+	return code, reason, nil
+}
+
+func closePayload(code StatusCode, reason []byte) ([]byte, error) {
+	if bits.Len(uint(code)) > 16 {
+		return nil, errors.New("status code is larger than 2 bytes")
+	}
+	if code == StatusNoStatusRcvd || code == StatusAbnormalClosure {
+		return nil, fmt.Errorf("status code %v cannot be set by applications", code)
+	}
+
+	buf := make([]byte, 2+len(reason))
+	binary.BigEndian.PutUint16(buf[:], uint16(code))
+	copy(buf[2:], reason)
+	return buf, nil
+}
diff --git a/statuscode_string.go b/statuscode_string.go
new file mode 100644
index 0000000000000000000000000000000000000000..d7ba0b553ad2b28856328fb9588657b226b7f296
--- /dev/null
+++ b/statuscode_string.go
@@ -0,0 +1,28 @@
+// Code generated by "stringer -type=StatusCode"; DO NOT EDIT.
+
+package ws
+
+import "strconv"
+
+const (
+	_StatusCode_name_0 = "StatusNormalClosureStatusGoingAwayStatusProtocolErrorStatusUnsupportedData"
+	_StatusCode_name_1 = "StatusNoStatusRcvdStatusAbnormalClosureStatusInvalidFramePayloadDataStatusPolicyViolationStatusMessageTooBigStatusMandatoryExtensionStatusInternalErrorStatusServiceRestartStatusTryAgainLaterStatusBadGatewayStatusTLSHandshake"
+)
+
+var (
+	_StatusCode_index_0 = [...]uint8{0, 19, 34, 53, 74}
+	_StatusCode_index_1 = [...]uint8{0, 18, 39, 68, 89, 108, 132, 151, 171, 190, 206, 224}
+)
+
+func (i StatusCode) String() string {
+	switch {
+	case 1000 <= i && i <= 1003:
+		i -= 1000
+		return _StatusCode_name_0[_StatusCode_index_0[i]:_StatusCode_index_0[i+1]]
+	case 1009 <= i && i <= 1019:
+		i -= 1009
+		return _StatusCode_name_1[_StatusCode_index_1[i]:_StatusCode_index_1[i+1]]
+	default:
+		return "StatusCode(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+}
diff --git a/test.sh b/test.sh
index 8eadf57362f7f827c73110cfeed6c4c58cda6787..38a65fe4c0cc764ba701892aba8307d83417f43a 100755
--- a/test.sh
+++ b/test.sh
@@ -1,7 +1,20 @@
 #!/usr/bin/env bash
+
 # This is for local testing. See .github for CI.
 
 source ./.github/lib.sh
 
-./.github/fmt/entrypoint.sh
-./.github/test/entrypoint.sh
+function docker_run() {
+	local dir=$1
+	docker run \
+		-v "${PWD}:/repo" \
+		-v "$(go env GOPATH):/go" \
+		-v "$(go env GOCACHE):/root/.cache/go-build" \
+		-w /repo \
+		"$(docker build -q "$dir")"
+}
+docker_run .github/fmt
+docker_run .github/test
+
+set +x
+echo "please open coverage.html to see detailed test coverage stats"
diff --git a/tools.go b/tools.go
index 5ef1d8739c004df486dcfb07163f9a3886f52490..35194f10888f7ee080f12554a1b3d5313d1beb93 100644
--- a/tools.go
+++ b/tools.go
@@ -4,6 +4,7 @@ package tools
 
 // See https://github.com/go-modules-by-example/index/blob/master/010_tools/README.md
 import (
-	_ "golang.org/x/tools/cmd/goimports"
+	_ "go.coder.com/go-tools/cmd/goimports"
+	_ "golang.org/x/tools/cmd/stringer"
 	_ "mvdan.cc/sh/cmd/shfmt"
 )
diff --git a/ws.go b/ws.go
new file mode 100644
index 0000000000000000000000000000000000000000..f56d57323df405743cfb8bb766e0f084e0a66d47
--- /dev/null
+++ b/ws.go
@@ -0,0 +1,79 @@
+package ws
+
+import (
+	"context"
+)
+
+const (
+	secWebSocketProtocol = "Sec-WebSocket-Protocol"
+)
+
+// Conn represents a WebSocket connection.
+type Conn struct {
+}
+
+// Subprotocol returns the negotiated subprotocol.
+// An empty string means the default protocol.
+func (c *Conn) Subprotocol() string {
+	panic("TODO")
+}
+
+// MessageWriter returns a writer bounded by the context that will write
+// a WebSocket data frame of type dataType to the connection.
+// Ensure you close the MessageWriter once you have written to entire message.
+func (c *Conn) MessageWriter(ctx context.Context, dataType DataType) *MessageWriter {
+	panic("TODO")
+}
+
+// ReadMessage will wait until there is a WebSocket data frame to read from the connection.
+// It returns the type of the data, a reader to read it and also an error.
+// Please use SetContext on the reader to bound the read operation.
+func (c *Conn) ReadMessage(ctx context.Context) (DataType, *MessageReader, error) {
+	panic("TODO")
+}
+
+// Close closes the WebSocket connection with the given status code and reason.
+// It will write a WebSocket close frame with a timeout of 5 seconds.
+func (c *Conn) Close(code StatusCode, reason string) error {
+	panic("TODO")
+}
+
+// MessageWriter enables writing to a WebSocket connection.
+// Ensure you close the MessageWriter once you have written to entire message.
+type MessageWriter struct {
+}
+
+// Write writes the given bytes to the WebSocket connection.
+// The frame will automatically be fragmented as appropriate
+// with the buffers obtained from http.Hijacker.
+// Please ensure you call Close once you have written the full message.
+func (w *MessageWriter) Write(p []byte) (n int, err error) {
+	panic("TODO")
+}
+
+// Close flushes the frame to the connection.
+// This must be called for every MessageWriter.
+func (w *MessageWriter) Close() error {
+	panic("TODO")
+}
+
+// MessageReader enables reading a data frame from the WebSocket connection.
+type MessageReader struct {
+}
+
+// SetContext bounds the read operation to the ctx.
+// By default, the context is the one passed to conn.ReadMessage.
+// You still almost always want a separate context for reading the message though.
+func (r *MessageReader) SetContext(ctx context.Context) {
+	panic("TODO")
+}
+
+// Limit limits the number of bytes read by the reader.
+func (r *MessageReader) Limit(bytes int) {
+	panic("TODO")
+}
+
+// Read reads as many bytes as possible into p.
+func (r *MessageReader) Read(p []byte) (n int, err error) {
+	panic("TODO")
+}
diff --git a/wscore/header.go b/wscore/header.go
new file mode 100644
index 0000000000000000000000000000000000000000..3f81df006727cddfe6cb1caa717c212217fd89dc
--- /dev/null
+++ b/wscore/header.go
@@ -0,0 +1,28 @@
+package wscore
+
+import (
+	"io"
+)
+
+// Header represents a WebSocket frame header.
+// See https://tools.ietf.org/html/rfc6455#section-5.2
+type Header struct {
+	Fin    bool
+	Rsv1   bool
+	Rsv2   bool
+	Rsv3   bool
+	Opcode Opcode
+
+	PayloadLength int64
+
+	Masked  bool
+	MaskKey [4]byte
+}
+
+func (h Header) Bytes() []byte {
+	panic("TODO")
+}
+
+func ReaderHeader(r io.Reader) []byte {
+	panic("TODO")
+}
diff --git a/wscore/mask.go b/wscore/mask.go
new file mode 100644
index 0000000000000000000000000000000000000000..c6c08ccd1e85ca8ff821ec957c64f2303fd20326
--- /dev/null
+++ b/wscore/mask.go
@@ -0,0 +1,15 @@
+package wscore
+
+// Mask applies the websocket masking algorithm to p
+// with the given key where the first 3 bits of pos
+// are the starting position in the key.
+// See https://tools.ietf.org/html/rfc6455#section-5.3
+//
+// It is highly optimized to mask per word with the usage
+// of unsafe.
+//
+// For targets that do not support unsafe, please report an issue.
+// There is a mask by byte function below that will be used for such targets.
+func Mask(key [4]byte, pos int, p []byte) int {
+	panic("TODO")
+}
diff --git a/wscore/opcode.go b/wscore/opcode.go
new file mode 100644
index 0000000000000000000000000000000000000000..dc36e920bab5b3e64b51d8001cc7119b976d1838
--- /dev/null
+++ b/wscore/opcode.go
@@ -0,0 +1,16 @@
+package wscore
+
+// Opcode represents a WebSocket Opcode.
+//go:generate stringer -type=Opcode
+type Opcode int
+
+const (
+	OpContinuation Opcode = iota
+	OpText
+	OpBinary
+	// 3 - 7 are reserved for further non-control frames.
+	OpClose Opcode = 8 + iota
+	OpPing
+	OpPong
+	// 11-16 are reserved for further control frames.
+)
diff --git a/wscore/opcode_string.go b/wscore/opcode_string.go
new file mode 100644
index 0000000000000000000000000000000000000000..2bc8a7231367de0c7b36c96f2d5ae3c8fcd5feae
--- /dev/null
+++ b/wscore/opcode_string.go
@@ -0,0 +1,27 @@
+// Code generated by "stringer -type=Opcode"; DO NOT EDIT.
+
+package wscore
+
+import "strconv"
+
+const (
+	_Opcode_name_0 = "OpContinuationOpTextOpBinary"
+	_Opcode_name_1 = "OpCloseOpPingOpPong"
+)
+
+var (
+	_Opcode_index_0 = [...]uint8{0, 14, 20, 28}
+	_Opcode_index_1 = [...]uint8{0, 7, 13, 19}
+)
+
+func (i Opcode) String() string {
+	switch {
+	case 0 <= i && i <= 2:
+		return _Opcode_name_0[_Opcode_index_0[i]:_Opcode_index_0[i+1]]
+	case 11 <= i && i <= 13:
+		i -= 11
+		return _Opcode_name_1[_Opcode_index_1[i]:_Opcode_index_1[i+1]]
+	default:
+		return "Opcode(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+}
diff --git a/wsjson/wsjon.go b/wsjson/wsjon.go
new file mode 100644
index 0000000000000000000000000000000000000000..72811e8ea5bffeb2cf83cfcab74da6ec404ba5ac
--- /dev/null
+++ b/wsjson/wsjon.go
@@ -0,0 +1,13 @@
+package wsjson
+
+import (
+	"nhooyr.io/ws"
+)
+
+func Read(c *ws.Conn, v interface{}) error {
+	panic("TODO")
+}
+
+func Write(c *ws.Conn, v interface{}) error {
+	panic("TODO")
+}
diff --git a/wspb/wspb.go b/wspb/wspb.go
new file mode 100644
index 0000000000000000000000000000000000000000..86741f59f9947fc6a114f562dde6cfc3df239d27
--- /dev/null
+++ b/wspb/wspb.go
@@ -0,0 +1,15 @@
+package wspb
+
+import (
+	"github.com/golang/protobuf/proto"
+
+	"nhooyr.io/ws"
+)
+
+func Read(c *ws.Conn, v proto.Message) error {
+	panic("TODO")
+}
+
+func Write(c *ws.Conn, v proto.Message) error {
+	panic("TODO")
+}