diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..60ceebc45d065739ee9ba5f0bad447e7c215e5f0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.swp
+*.swo
+*.swn
+ignore/
diff --git a/README.md b/README.md
index 609b3daffaea53a36db9e532e8f65da9171cff01..3ded99be05e6aecbb9c8f9ee62952485104bed46 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,10 @@
 # snowflake-pt
 
+WebRTC Pluggable Transport
+
+### Usage
+
+`go build webrtc-client.go`
+`tor -f torrc`
+
+More documentation on the way.
diff --git a/proxy/koch.jpg b/proxy/koch.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..cf210efc941edc5675d91a7b80a53f56a60b851c
Binary files /dev/null and b/proxy/koch.jpg differ
diff --git a/proxy/snowflake.html b/proxy/snowflake.html
new file mode 100644
index 0000000000000000000000000000000000000000..cff00a9f9e68190d58aecd9684af6a8671368e26
--- /dev/null
+++ b/proxy/snowflake.html
@@ -0,0 +1,78 @@
+<!doctype html>
+<html>
+<head>
+  <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+  <script src="snowflake.js"></script>
+  <style>
+  * {
+    box-sizing: border-box;
+    -webkit-transition: all 0.3s;
+    -moz-transition: all 0.3s;
+    transition: all 0.3s;
+  }
+  body {
+    position: absolute;
+    width: 100%; height: 100%; top: 0; margin: 0 auto;
+    background-color: #424;
+    color: #000;
+    text-align: center;
+    font-size: 24px;
+    font-family: monospace;
+    background-image: url('koch.jpg');
+  }
+  textarea {
+    background-color: rgba(0,0,0,0.8);
+    color: #fff;
+    resize: none;
+  }
+  .chatarea {
+    position: relative; border: none;
+    width: 50%; min-width: 40em;
+    padding: 0.5em; margin: auto;
+  }
+  .active { background-color: #252; }
+  #chatlog {
+    display: block;
+    width: 100%;
+    min-height: 40em;
+    margin-bottom: 1em;
+    padding: 8px;
+  }
+  .inputarea {
+    position: relative;
+    width: 100%;
+    height: 3em;
+    display: block;
+  }
+  #input {
+    display: inline-block;
+    position: absolute; left: 0;
+    width: 89%; height: 100%;
+    padding: 8px 30px;
+    font-size: 80%;
+    color: #fff;
+    background-color: rgba(0,0,0,0.9);
+    border: 1px solid #999;
+  }
+  #send {
+    display: inline-block; position: absolute;
+    right: 0; top: 0;  height: 100%; width: 10%;
+    background-color: #202; color: #f8f;
+    font-variant: small-caps; font-size: 100%;
+    border: none; // box-shadow: 0 2px 5px #000;
+  }
+  #send:hover { background-color: #636; }
+  </style>
+</head>
+<body>
+  <div class="chatarea">
+    <textarea id="chatlog" readonly>
+    </textarea>
+    <div class="inputarea">
+      <input type="text" id="input">
+      <input type="submit" id="send" value="send">
+    </div>
+  </div>
+
+</body>
+</html>
diff --git a/proxy/snowflake.js b/proxy/snowflake.js
new file mode 100644
index 0000000000000000000000000000000000000000..d6fc0256acd036087b2bd6d531261966efa011c4
--- /dev/null
+++ b/proxy/snowflake.js
@@ -0,0 +1,239 @@
+/*
+JS WebRTC proxy
+Copy-paste signaling.
+*/
+
+// DOM elements
+var $chatlog, $input, $send, $name;
+
+var config = {
+  iceServers: [
+    { urls: ["stun:stun.l.google.com:19302"] }
+  ]
+}
+
+// Chrome / Firefox compatibility
+window.PeerConnection = window.RTCPeerConnection ||
+                        window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate;
+window.RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription;
+
+var pc;  // PeerConnection
+var answer;
+// Janky state machine
+var MODE = {
+  INIT:       0,
+  CONNECTING: 1,
+  CHAT:       2
+}
+var currentMode = MODE.INIT;
+
+// Signalling channel - just tells user to copy paste to the peer.
+var Signalling = {
+  send: function(msg) {
+    log("---- Please copy the below to peer ----\n");
+    log(JSON.stringify(msg));
+    log("\n");
+  },
+  receive: function(msg) {
+    var recv;
+    try {
+      recv = JSON.parse(msg);
+    } catch(e) {
+      log("Invalid JSON.");
+      return;
+    }
+    if (!pc) {
+      start(false);
+    }
+    var desc = recv['sdp']
+    var ice = recv['candidate']
+    if (!desc && ! ice) {
+      log("Invalid SDP.");
+      return false;
+    }
+    if (desc) { receiveDescription(recv); }
+    if (ice) { receiveICE(recv); }
+  }
+}
+
+function welcome() {
+  log("== snowflake JS proxy ==");
+  log("Input offer from the snowflake client:");
+}
+
+function start(initiator) {
+      username + ": " + msg;
+  log("Starting up RTCPeerConnection...");
+  pc = new PeerConnection(config, {
+    optional: [
+      { DtlsSrtpKeyAgreement: true },
+      { RtpDataChannels: false },
+    ],
+  });
+  pc.onicecandidate = function(evt) {
+    var candidate = evt.candidate;
+    // Chrome sends a null candidate once the ICE gathering phase completes.
+    // In this case, it makes sense to send one copy-paste blob.
+    if (null == candidate) {
+      log("Finished gathering ICE candidates.");
+      Signalling.send(pc.localDescription);
+      return;
+    }
+  }
+  pc.onnegotiationneeded = function() {
+    sendOffer();
+  }
+  pc.ondatachannel = function(dc) {
+    console.log(dc);
+    channel = dc.channel;
+    log("Data Channel established... ");
+    prepareDataChannel(channel);
+  }
+
+  // Creating the first data channel triggers ICE negotiation.
+  if (initiator) {
+    channel = pc.createDataChannel("test");
+    prepareDataChannel(channel);
+  }
+}
+
+// Local input from keyboard into chat window.
+function acceptInput(is) {
+  var msg = $input.value;
+  switch (currentMode) {
+    case MODE.INIT:
+      if (msg.startsWith("start")) {
+        start(true);
+      } else {
+        Signalling.receive(msg);
+      }
+      break;
+    case MODE.CONNECTING:
+      Signalling.receive(msg);
+      break;
+    case MODE.CHAT:
+      var data = msg;
+      log(data);
+      channel.send(data);
+      break;
+    default:
+      log("ERROR: " + msg);
+  }
+  $input.value = "";
+  $input.focus();
+}
+
+// Chrome uses callbacks while Firefox uses promises.
+// Need to support both - same for createAnswer below.
+function sendOffer() {
+  var next = function(sdp) {
+    log("webrtc: Created Offer");
+    offer = sdp;
+    pc.setLocalDescription(sdp);
+  }
+  var promise = pc.createOffer(next);
+  if (promise) {
+    promise.then(next);
+  }
+}
+
+function sendAnswer() {
+  var next = function (sdp) {
+    log("webrtc: Created Answer");
+    answer = sdp;
+    pc.setLocalDescription(sdp)
+  }
+  var promise = pc.createAnswer(next);
+  if (promise) {
+    promise.then(next);
+  }
+}
+
+function receiveDescription(desc) {
+  var sdp = new RTCSessionDescription(desc);
+  try {
+    err = pc.setRemoteDescription(sdp);
+  } catch (e) {
+    log("Invalid SDP message.");
+    return false;
+  }
+  log("SDP " + sdp.type + " successfully received.");
+  if ("offer" == sdp.type) {
+    sendAnswer();
+  }
+  return true;
+}
+
+function receiveICE(ice) {
+  var candidate = new RTCIceCandidate(ice);
+  try {
+    pc.addIceCandidate(candidate);
+  } catch (e) {
+    log("Could not add ICE candidate.");
+    return;
+  }
+  log("ICE candidate successfully received: " + ice.candidate);
+}
+
+function waitForSignals() {
+  currentMode = MODE.CONNECTING;
+}
+
+function prepareDataChannel(channel) {
+  channel.onopen = function() {
+    log("Data channel opened!");
+    startChat();
+  }
+  channel.onclose = function() {
+    log("Data channel closed.");
+    currentMode = MODE.INIT;
+    $chatlog.className = "";
+    log("------- chat disabled -------");
+  }
+  channel.onerror = function() {
+    log("Data channel error!!");
+  }
+  channel.onmessage = function(msg) {
+    var recv = msg.data;
+    console.log(msg);
+    // Go sends only raw bytes.
+    if ("[object ArrayBuffer]" == recv.toString()) {
+      var bytes = new Uint8Array(recv);
+      line = String.fromCharCode.apply(null, bytes);
+    } else {
+      line = recv;
+    }
+    line = line.trim();
+    log(line);
+  }
+}
+
+// Get DOM elements and setup interactions.
+function init() {
+  console.log("loaded");
+  // Setup chatwindow.
+  $chatlog = document.getElementById('chatlog');
+  $chatlog.value = "";
+
+  $send = document.getElementById('send');
+  $send.onclick = acceptInput
+
+  $input = document.getElementById('input');
+  $input.focus();
+  $input.onkeydown = function(e) {
+    if (13 == e.keyCode) {  // enter
+      $send.onclick();
+    }
+  }
+  welcome();
+}
+
+var log = function(msg) {
+  $chatlog.value += msg + "\n";
+  console.log(msg);
+  // Scroll to latest.
+  $chatlog.scrollTop = $chatlog.scrollHeight;
+}
+
+window.onload = init;
diff --git a/webrtc-client.go b/webrtc-client.go
index 09df4fb3ed6ea2881c09ce7cb662db3417e6c422..b17ac7012318cea0bc66aec64c657088a4876862 100644
--- a/webrtc-client.go
+++ b/webrtc-client.go
@@ -35,6 +35,9 @@ func handler(conn *pt.SocksConn) error {
 		return err
 	}
 
+	// For now, the Go client is always the offerer.
+	// TODO: Copy paste signaling
+
 	pc.OnNegotiationNeeded = func() {
 		// log.Println("OnNegotiationNeeded")
 		go func() {