diff --git a/dev/dev.htm b/dev/dev.htm
index 4f1b702..446f109 100644
--- a/dev/dev.htm
+++ b/dev/dev.htm
@@ -166,11 +166,12 @@ async function updateCharts() {
 }
 
 const sleep = (ms) => new Promise(r => setTimeout(r, ms))
+var chart_update_millis = 2000
 
 async function chartupdater() {
   if (chart_updater != null) return
 
-  await sleep(2000)
+  await sleep(chart_update_millis)
   // wait at least two seconds to avoid wasting a lot of bandwidth
   const resp = await fetch("/api/update")
   chart_updater = null
@@ -200,6 +201,19 @@ async function waitThenUpdateStatus() {
   updateStatus()
 }
 
+async function testAdminMode() {
+  const msg = JSON.stringify({
+    auth: "password",
+    data: {
+      manual_mode: true
+    }
+  })
+  await fetch("/api/admin", {
+    method: "POST",
+    body: msg
+  })
+}
+
 window.onload = () => {
   initCharts()
   updateCharts()
@@ -212,6 +226,9 @@ window.onload = () => {
   document.getElementById('autoupdate').addEventListener('click', (e) => {
     autoupdate = document.getElementById('autoupdate').checked
   })
+  document.getElementById('test-admin').addEventListener('click', (e) => {
+    testAdminMode()
+  })
 }
     </script>
   </head>
@@ -223,6 +240,7 @@ window.onload = () => {
       <form>
         <input type=checkbox id=autoupdate checked>Autoupdate</input>
         <input type=button id=update value="Update"></input>
+        <input type=button id=test-admin value="Test admin mode"></input>
         <!-- TODO add decimation and window size options -->
         <span id=status></span>
       </form>
diff --git a/shroom_internals/tcp_server.go b/shroom_internals/tcp_server.go
index bf32325..20b5958 100644
--- a/shroom_internals/tcp_server.go
+++ b/shroom_internals/tcp_server.go
@@ -31,6 +31,8 @@ type ShroomStatus struct {
 	NumConnections int
 	Wait           chan struct{}
 	StatusWait     chan struct{}
+
+	Commands chan []byte
 }
 
 func (s *ShroomStatus) Update() {
@@ -71,7 +73,7 @@ func parseMsg(line []byte, db *sql.DB, status *ShroomStatus) {
 		// we got a data packet
 		status.Update()
 	} else if data.Status != -1 {
-		log.Println("received status ", data.Status)
+		//log.Println("received status ", data.Status)
 		status.Lock()
 		// TODO change to have more detailed data
 		status.HumidifierOn = (data.Status & 1) == 1
@@ -96,6 +98,12 @@ func InitTcpServer(db *sql.DB, status *ShroomStatus) {
 				log.Println("tcp accept error: ", err)
 				return
 			}
+			// not spawning a goroutine here
+			// should limit the number of connections to
+			// one hopefully
+
+			// wrapping in a func() so that I can use defer
+			// to automatically decrement the number of connections
 			func() {
 				status.Lock()
 				status.NumConnections += 1
@@ -112,8 +120,27 @@ func InitTcpServer(db *sql.DB, status *ShroomStatus) {
 
 				log.Println("connection started")
 
+				// write loop; waits for commands and forwards them
+				exiting := make(chan struct{})
 				go func() {
-					// TODO deal with the write side of the connection
+					for {
+						select {
+						case v, ok := <-status.Commands:
+							if !ok {
+								return
+							}
+							v = append(v, []byte("\n")...)
+							_, err := conn.Write(v)
+							if err != nil {
+								log.Println("tcp write err: ", err)
+							}
+						case <-exiting:
+							return
+						}
+					}
+				}()
+				defer func() {
+					close(exiting)
 				}()
 
 				// deal with the read side of the connection
diff --git a/shroom_server.go b/shroom_server.go
index 4007fd9..0b194fb 100644
--- a/shroom_server.go
+++ b/shroom_server.go
@@ -29,6 +29,11 @@ type statusJson struct {
 	Humidifier bool `json:"humidifier"`
 }
 
+type adminMsg struct {
+	Auth string                 `json:"auth"`
+	Msg  map[string]interface{} `json:"data"`
+}
+
 func dumpData(db *sql.DB, multiplier int64) func(http.ResponseWriter, *http.Request) {
 	return func(w http.ResponseWriter, req *http.Request) {
 		now := time.Now().Unix()
@@ -70,6 +75,7 @@ func main() {
 	status := s.ShroomStatus{
 		Wait:       make(chan struct{}),
 		StatusWait: make(chan struct{}),
+		Commands:   make(chan []byte),
 	}
 	s.InitTcpServer(db, &status)
 
@@ -106,7 +112,7 @@ func main() {
 		msg, err := json.Marshal(s)
 		if err != nil {
 			err = fmt.Errorf("unable to marshal json: %w", err)
-			w.WriteHeader(500)
+			w.WriteHeader(400)
 			w.Write([]byte(err.Error()))
 		} else {
 			w.Write(msg)
@@ -114,9 +120,49 @@ func main() {
 	}
 
 	adminHandler := func(w http.ResponseWriter, req *http.Request) {
-		w.WriteHeader(500)
-		w.Write([]byte("unimplemented"))
-		// TODO
+		if req.Method != "POST" {
+			w.WriteHeader(405)
+			w.Write([]byte("method must be POST"))
+			return
+		}
+		msg := make([]byte, 256)
+		l, err := req.Body.Read(msg)
+		if err != nil && l == 0 {
+			err = fmt.Errorf("unable to read body: %w", err)
+			w.WriteHeader(400)
+			w.Write([]byte(err.Error()))
+			return
+		}
+		adminReq := adminMsg{}
+		err = json.Unmarshal(msg[:l], &adminReq)
+		if err != nil {
+			err = fmt.Errorf("unable to unmarshal body as json: %w", err)
+			w.WriteHeader(400)
+			w.Write([]byte(err.Error()))
+			return
+		}
+
+		// TODO switch to embedded secret
+		if adminReq.Auth != "password" {
+			w.WriteHeader(401)
+			w.Write([]byte(err.Error()))
+			return
+		}
+
+		inner_msg, err := json.Marshal(adminReq.Msg)
+		if err != nil {
+			err = fmt.Errorf("unable to marshal inner message: %w", err)
+			w.WriteHeader(400)
+			w.Write([]byte(err.Error()))
+			return
+		}
+		select {
+		case status.Commands <- inner_msg:
+			w.Write([]byte("ok"))
+		default:
+			w.WriteHeader(503)
+			w.Write([]byte("unable to forward request; controller may not be connected"))
+		}
 	}
 
 	updateHandler := func(w http.ResponseWriter, req *http.Request) {