diff --git a/README.md b/README.md
index 5e86475ffb91c13d22b63a641ffd9a994845455a..ffd25644ee18a47274a0bef9e46f071f11212337 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ This repository contains a number of client under the `cmd` directory.
 Currently, these are:
 
 - **abhelp**: Request help
+- **abcli**: Handle requests in a queue
 
 Meta
 ----
@@ -20,3 +21,4 @@ the FAU CS [GitLab][gitlab].
 [ab]: https://github.com/noctux/adora-belle
 [go]: https://golang.org/
 [license]: ./LICENSE
+[gitlab]: https://gitlab.cs.fau.de/oj14ozun/ablib
diff --git a/cmd/abcli/cli.go b/cmd/abcli/cli.go
new file mode 100644
index 0000000000000000000000000000000000000000..5dd44b5488a5134ef13f44ddc9e88a57f29845ce
--- /dev/null
+++ b/cmd/abcli/cli.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+	"ablib"
+	"fmt"
+	"os"
+	"sync"
+
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+type cmd struct {
+	cmd  ablib.Command
+	data string
+	arg  string
+}
+type cli struct {
+	sync.Mutex
+	u string
+	q []*ablib.Request
+	c chan *cmd
+}
+
+func (c *cli) interaction() {
+	var (
+		r    *ablib.Request
+		char byte
+		verb string
+	)
+
+	old, err := terminal.MakeRaw(0)
+	if err != nil {
+		panic(err)
+	}
+
+	for {
+		fmt.Scanf("%c", &char)
+		switch char {
+		case 'q', 0x3:
+			c.Lock()
+			terminal.Restore(0, old)
+			os.Exit(0)
+		case 'd', ' ':
+			verb = "Discard"
+		case 'h', '\r':
+			verb = "Handle"
+		}
+
+		c.Lock()
+		r = c.q[0]
+		c.q = c.q[1:]
+		c.Unlock()
+		c.c <- &cmd{ablib.Handle, r.ID, verb}
+	}
+}
+
+func (c *cli) HandleError(err error) {
+	fmt.Fprintf(os.Stderr, "ERROR: %s", err)
+}
+
+func (c *cli) OnRequest(r *ablib.Request, handled string) {
+	if handled != "" {
+		return
+	}
+
+	c.Lock()
+	defer c.Unlock()
+	c.q = append(c.q, r)
+
+	fmt.Printf("% 4d: new \"%s\" request by %s (%s)\n",
+		len(c.q), r.Type, r.User, r.ID)
+}
+
+func (c *cli) OnAction(a *ablib.Action) {
+	// remove request from queue
+	c.Lock()
+	defer c.Unlock()
+	k := -1
+	for i, r := range c.q {
+		if r == a.Request {
+			k = i
+			break
+		}
+	}
+	if k != -1 {
+		// from https://github.com/golang/go/wiki/SliceTricks
+		copy(c.q[k:], c.q[k+1:])
+		c.q[len(c.q)-1] = nil
+		c.q = c.q[:len(c.q)-1]
+	}
+
+	if a.Name == c.u {
+		fmt.Printf("% 4d: handling %s: %s\n",
+			len(c.q), a.Request.ID, a.Request.Room)
+	} else {
+		fmt.Printf("% 4d: request %s handled by %s\n",
+			len(c.q), a.Request.ID, a.Name)
+	}
+}
+
+func (c *cli) Listen() (ablib.Command, string, string, error) {
+	cmd := <-c.c
+	return cmd.cmd, cmd.data, cmd.arg, nil
+}
diff --git a/cmd/abcli/main.go b/cmd/abcli/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..0823972d887a922a713075da06dd17c56b4d2e23
--- /dev/null
+++ b/cmd/abcli/main.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+	"ablib"
+	"flag"
+	"fmt"
+	"os"
+	"time"
+)
+
+const usage = "Usage: %s [options] [help|serve|request]"
+
+func main() {
+	var (
+		interval          uint
+		user, pass, class string
+	)
+
+	// parse flags
+	flag.UintVar(&interval, "interval", 10, "interval in seconds between re-query")
+	flag.StringVar(&class, "class", os.Getenv("AB_CLASS"), "name of class")
+	flag.StringVar(&user, "user", os.Getenv("AB_USER"), "")
+	flag.StringVar(&pass, "pass", os.Getenv("AB_PASS"), "")
+	flag.Parse()
+
+	// set up api
+	api := ablib.API{
+		Interval: time.Duration(interval) * time.Second,
+		User:     user,
+		Pass:     pass,
+	}
+
+	if !api.SetupClass(class) {
+		fmt.Fprintln(os.Stderr, "Unknown class name \"%s\"", class)
+		os.Exit(1)
+	}
+
+	// start ui loop
+	ui := &cli{u: user, c: make(chan *cmd)}
+	go ui.interaction()
+	if err := api.Run(ui); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}