diff --git a/client/image.go b/client/image.go
new file mode 100644
index 0000000000000000000000000000000000000000..08c1b7b85027263a49592999c3042ddfbb213064
--- /dev/null
+++ b/client/image.go
@@ -0,0 +1,140 @@
+package client
+
+import (
+	"bytes"
+	"mime/multipart"
+	"net/http"
+	"random.chars.jp/git/image-board/api"
+	"random.chars.jp/git/image-board/store"
+	"strconv"
+)
+
+// Images returns a slice of snowflakes of all images. Only available to privileged users.
+func (r *Remote) Images() ([]string, error) {
+	var flakes []string
+	err := r.fetch(http.MethodGet, api.Image, &flakes, nil)
+	return flakes, err
+}
+
+// ImageAdd adds an image to Remote and returns a store.Image.
+func (r *Remote) ImageAdd(data []byte) (store.Image, error) {
+	buf := &bytes.Buffer{}
+	w := multipart.NewWriter(buf)
+	if f, err := w.CreateFormFile("image", ""); err != nil {
+		return store.Image{}, err
+	} else {
+		if _, err = f.Write(data); err != nil {
+			return store.Image{}, err
+		}
+	}
+
+	if err := w.Close(); err != nil {
+		return store.Image{}, err
+	}
+
+	if resp, err := r.request(http.MethodPost, api.Image, buf); err != nil {
+		return store.Image{}, err
+	} else {
+		var image store.Image
+		err = unmarshal(resp.Body, &image)
+		return image, err
+	}
+}
+
+// Image returns store.Image with given snowflake.
+func (r *Remote) Image(flake string) (store.Image, error) {
+	var image store.Image
+	err := r.fetch(http.MethodGet, populateField(api.ImageField, "flake", flake), &image, nil)
+	return image, err
+}
+
+// ImageUpdate updates metadata of store.Image with given snowflake. To persist original value in a field set \000.
+func (r *Remote) ImageUpdate(flake, source, commentary, commentaryTranslation string) error {
+	payload := api.ImageUpdatePayload{
+		Source:                source,
+		Commentary:            commentary,
+		CommentaryTranslation: commentaryTranslation,
+	}
+	return r.requestJSONnoResp(http.MethodPatch, populateField(api.ImageField, "flake", flake), payload)
+}
+
+// ImageDestroy destroys a store.Image with given snowflake.
+func (r *Remote) ImageDestroy(flake string) error {
+	return r.requestNoResp(http.MethodDelete, populateField(api.ImageField, "flake", flake), nil)
+}
+
+// ImageFile returns a slice of bytes of the image with given snowflake.
+func (r *Remote) ImageFile(flake string, preview bool) ([]byte, error) {
+	switch preview {
+	case true:
+		return r.fetchAll(http.MethodGet, populateField(api.ImagePreview, "flake", flake), nil)
+	case false:
+		return r.fetchAll(http.MethodGet, populateField(api.ImageFile, "flake", flake), nil)
+	default:
+		return nil, nil
+	}
+}
+
+// ImageTag returns a slice of tags of an image with given snowflake.
+func (r *Remote) ImageTag(flake string) ([]string, error) {
+	var tags []string
+	err := r.fetch(http.MethodGet, populateField(api.ImageTag, "flake", flake), &tags, nil)
+	return tags, err
+}
+
+// ImageTagAdd adds a tag to an image with given snowflake.
+func (r *Remote) ImageTagAdd(flake, tag string) error {
+	return r.requestNoResp(http.MethodPut,
+		populateField(populateField(api.ImageTagField,
+			"flake", flake),
+			"tag", tag),
+		nil)
+}
+
+// ImageTagRemove removes a tag from an image with given snowflake.
+func (r *Remote) ImageTagRemove(flake, tag string) error {
+	return r.requestNoResp(http.MethodDelete,
+		populateField(populateField(api.ImageTagField,
+			"flake", flake),
+			"tag", tag),
+		nil)
+}
+
+// ImagePages returns total amount of image pages.
+func (r *Remote) ImagePages() (int, error) {
+	if payload, err := r.fetchAll(http.MethodGet, api.ImagePage, nil); err != nil {
+		return 0, err
+	} else {
+		var pages int
+		if pages, err = strconv.Atoi(string(payload)); err != nil {
+			return 0, err
+		} else {
+			return pages, nil
+		}
+	}
+}
+
+// ImagePage returns a slice of snowflakes of images in a page.
+func (r *Remote) ImagePage(entry int) ([]string, error) {
+	var flakes []string
+	err := r.fetch(http.MethodGet, populateField(api.ImagePageField, "entry", strconv.Itoa(entry)), &flakes, nil)
+	return flakes, err
+}
+
+// ImagePageImage returns a slice of store.Image in a page.
+func (r *Remote) ImagePageImage(entry int) ([]store.Image, error) {
+	var images []store.Image
+	err := r.fetch(http.MethodGet, populateField(api.ImagePageImage, "entry", strconv.Itoa(entry)), &images, nil)
+	return images, err
+}
+
+// ImageSearch searches for images that contains all specified tags.
+func (r *Remote) ImageSearch(tags []string) ([]string, error) {
+	t := tags[0]
+	for i := 1; i < len(tags); i++ {
+		t += "!" + tags[i]
+	}
+	var flakes []string
+	err := r.fetch(http.MethodGet, populateField(api.SearchField, "tags", t), &flakes, nil)
+	return flakes, err
+}
diff --git a/client/misc.go b/client/misc.go
new file mode 100644
index 0000000000000000000000000000000000000000..b37f5682c9a4858b81b771df79cbbc15a0214c6a
--- /dev/null
+++ b/client/misc.go
@@ -0,0 +1,7 @@
+package client
+
+import "strings"
+
+func populateField(path, field, content string) string {
+	return strings.Replace(path, ":"+field, content, 1)
+}
diff --git a/client/remote.go b/client/remote.go
new file mode 100644
index 0000000000000000000000000000000000000000..3391cc96342336d40449ca98cf46799640db3915
--- /dev/null
+++ b/client/remote.go
@@ -0,0 +1,56 @@
+package client
+
+import (
+	"fmt"
+	"net/http"
+	"random.chars.jp/git/image-board/api"
+	"random.chars.jp/git/image-board/store"
+)
+
+// Remote represents a remote image board server.
+type Remote struct {
+	url    string
+	single bool
+	secret string
+	client *http.Client
+	user   *api.UserPayload
+	store.Info
+}
+
+// New returns a pointer to a new Remote.
+func New(url string) (*Remote, error) {
+	remote := &Remote{url: url, client: &http.Client{}}
+	return remote, remote.Handshake()
+}
+
+// URL returns the URL of Remote.
+func (r *Remote) URL(path string) string {
+	return r.url + path
+}
+
+// SingleUser returns whether the Remote is running in single user mode.
+func (r *Remote) SingleUser() bool {
+	return r.single
+}
+
+// Handshake checks if the server is still online and updates Remote.
+func (r *Remote) Handshake() error {
+	if err := r.fetch(http.MethodGet, api.SingleUser, r.single, nil); err != nil {
+		return err
+	}
+	return r.fetch(http.MethodGet, api.Base, r, nil)
+}
+
+// Secret authenticates and sets secret.
+func (r *Remote) Secret(secret string) (api.UserPayload, bool) {
+	prev := r.secret
+	r.secret = secret
+	if user, err := r.This(); err != nil {
+		fmt.Printf("Error getting user post secret change, %s", err)
+		r.secret = prev
+		return api.UserPayload{}, false
+	} else {
+		r.user = &user
+		return user, true
+	}
+}
diff --git a/client/request.go b/client/request.go
new file mode 100644
index 0000000000000000000000000000000000000000..ecb3569afdf09d9d03313d2d4782f41e32437558
--- /dev/null
+++ b/client/request.go
@@ -0,0 +1,101 @@
+package client
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+)
+
+func (r *Remote) send(req *http.Request) (*http.Response, error) {
+	if req == nil {
+		return nil, nil
+	}
+
+	if r.secret != "" {
+		req.Header.Add("secret", r.secret)
+	}
+
+	return r.client.Do(req)
+}
+
+func (r *Remote) request(method, path string, body io.Reader) (*http.Response, error) {
+	if req, err := http.NewRequest(method, r.URL(path), body); err != nil {
+		return nil, err
+	} else {
+		return r.send(req)
+	}
+}
+
+func (r *Remote) requestNoResp(method, path string, body io.Reader) error {
+	if resp, err := r.request(method, path, body); err != nil {
+		return err
+	} else {
+		return resp.Body.Close()
+	}
+}
+
+func (r *Remote) requestJSON(method, path string, v interface{}) (*http.Response, error) {
+	var reader *bytes.Reader
+	if v != nil {
+		if payload, err := json.Marshal(v); err != nil {
+			return nil, err
+		} else {
+			reader = bytes.NewReader(payload)
+		}
+	}
+	return r.request(method, path, reader)
+}
+
+func (r *Remote) requestJSONnoResp(method, path string, v interface{}) error {
+	if resp, err := r.requestJSON(method, path, v); err != nil {
+		return err
+	} else {
+		return resp.Body.Close()
+	}
+}
+
+func (r *Remote) fetch(method, path string, v interface{}, body io.Reader) error {
+	if resp, err := r.request(method, path, body); err != nil {
+		return err
+	} else {
+		if v == nil {
+			return nil
+		}
+		if err = unmarshal(resp.Body, v); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (r *Remote) fetchAll(method, path string, body io.Reader) ([]byte, error) {
+	if resp, err := r.request(method, path, body); err != nil {
+		return nil, err
+	} else {
+		defer func() {
+			if err = resp.Body.Close(); err != nil {
+				fmt.Printf("Error closing body after reading all, %s\n", err)
+			}
+		}()
+		var content []byte
+		if content, err = io.ReadAll(resp.Body); err != nil {
+			return nil, err
+		} else {
+			return content, nil
+		}
+	}
+}
+
+func unmarshal(reader io.ReadCloser, v interface{}) error {
+	defer func() {
+		if err := reader.Close(); err != nil {
+			fmt.Printf("Error closing reader after unmarshalling, %s\n", err)
+		}
+	}()
+	if err := json.NewDecoder(reader).Decode(v); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/client/tag.go b/client/tag.go
new file mode 100644
index 0000000000000000000000000000000000000000..37b8745cdd7532029610f90774872c62f7d1da13
--- /dev/null
+++ b/client/tag.go
@@ -0,0 +1,81 @@
+package client
+
+import (
+	"net/http"
+	"random.chars.jp/git/image-board/api"
+	"random.chars.jp/git/image-board/store"
+	"strconv"
+)
+
+// Tags returns a slice of tag names on Remote.
+func (r *Remote) Tags() ([]string, error) {
+	var tags []string
+	err := r.fetch(http.MethodGet, api.Tag, &tags, nil)
+	return tags, err
+}
+
+// Tag returns a slice of snowflakes in a given tag.
+func (r *Remote) Tag(tag string) ([]string, error) {
+	var flakes []string
+	err := r.fetch(http.MethodGet, populateField(api.TagField, "tag", tag), &flakes, nil)
+	return flakes, err
+}
+
+// TagPage returns a slice of snowflakes in a page of a given tag.
+func (r *Remote) TagPage(tag string, entry int) ([]string, error) {
+	var flakes []string
+	err := r.fetch(http.MethodGet,
+		populateField(populateField(api.TagPageField,
+			"tag", tag),
+			"entry", strconv.Itoa(entry)),
+		&flakes, nil)
+	return flakes, err
+}
+
+// TagPageImages returns a slice of store.Image in a page of a given tag.
+func (r *Remote) TagPageImages(tag string, entry int) ([]store.Image, error) {
+	var images []store.Image
+	err := r.fetch(http.MethodGet,
+		populateField(populateField(api.TagPageImage,
+			"tag", tag),
+			"entry", strconv.Itoa(entry)),
+		&images, nil)
+	return images, err
+}
+
+// TagPages returns total amount of pages of a tag.
+func (r *Remote) TagPages(tag string) (int, error) {
+	if payload, err := r.fetchAll(http.MethodGet,
+		populateField(api.TagPage, "tag", tag), nil); err != nil {
+		return 0, err
+	} else {
+		var pages int
+		if pages, err = strconv.Atoi(string(payload)); err != nil {
+			return 0, err
+		} else {
+			return pages, nil
+		}
+	}
+}
+
+// TagCreate creates a tag on Remote.
+func (r *Remote) TagCreate(tag string) error {
+	return r.requestNoResp(http.MethodPut, populateField(api.TagField, "tag", tag), nil)
+}
+
+// TagDestroy destroys a tag on Remote.
+func (r *Remote) TagDestroy(tag string) error {
+	return r.requestNoResp(http.MethodDelete, populateField(api.TagField, "tag", tag), nil)
+}
+
+// TagInfo returns store.Tag of given tag.
+func (r *Remote) TagInfo(tag string) (store.Tag, error) {
+	var t store.Tag
+	err := r.fetch(http.MethodGet, populateField(api.TagInfo, "tag", tag), &t, nil)
+	return t, err
+}
+
+// TagType sets type of given tag.
+func (r *Remote) TagType(tag, t string) error {
+	return r.requestJSONnoResp(http.MethodPatch, populateField(api.TagInfo, "tag", tag), api.TagUpdatePayload{Type: t})
+}
diff --git a/client/user.go b/client/user.go
new file mode 100644
index 0000000000000000000000000000000000000000..97985e6d5df0c9cb25e5a50765b36040c49813eb
--- /dev/null
+++ b/client/user.go
@@ -0,0 +1,107 @@
+package client
+
+import (
+	"net/http"
+	"random.chars.jp/git/image-board/api"
+	"random.chars.jp/git/image-board/store"
+	"strings"
+)
+
+// User returns api.UserPayload of a snowflake.
+func (r *Remote) User(flake string) (api.UserPayload, error) {
+	var user api.UserPayload
+	err := r.fetch(http.MethodGet, api.User+"/"+flake, &user, nil)
+	return user, err
+}
+
+// This returns api.UserPayload currently authenticated as.
+func (r *Remote) This() (api.UserPayload, error) {
+	return r.User("this")
+}
+
+// Username returns api.UserPayload of username.
+func (r *Remote) Username(name string) (api.UserPayload, error) {
+	var user api.UserPayload
+	err := r.fetch(http.MethodGet, api.Username+"/"+name, &user, nil)
+	return user, err
+}
+
+// Auth authenticates using a username and its corresponding password.
+func (r *Remote) Auth(username, password string) (string, error) {
+	var secret api.UserSecretPayload
+	err := r.fetch(http.MethodPost,
+		populateField(api.UsernameAuth, "name", username),
+		&secret,
+		strings.NewReader(password))
+	return secret.Secret, err
+}
+
+// UserAdd adds a new user.
+func (r *Remote) UserAdd(username string, password string, privileged bool) (store.User, error) {
+	if resp, err := r.requestJSON(http.MethodPut, api.User, api.UserCreatePayload{
+		Username:   username,
+		Password:   password,
+		Privileged: privileged,
+	}); err != nil {
+		return store.User{}, err
+	} else {
+		var user store.User
+		err = unmarshal(resp.Body, &user)
+		return user, err
+	}
+}
+
+// UserUpdate updates a user.
+func (r *Remote) UserUpdate(flake, newname string) error {
+	return r.requestJSONnoResp(http.MethodPatch,
+		populateField(api.UserField, "flake", flake),
+		api.UserUpdatePayload{Username: newname})
+}
+
+// UserDestroy destroys a user.
+func (r *Remote) UserDestroy(flake string) error {
+	return r.requestJSONnoResp(http.MethodDelete,
+		populateField(api.UserField, "flake", flake),
+		nil)
+}
+
+// UserDisable disables a user.
+func (r *Remote) UserDisable(flake string) error {
+	return r.requestJSONnoResp(http.MethodDelete,
+		populateField(api.UserPassword, "flake", flake),
+		nil)
+}
+
+// UserPassword sets a user's password.
+func (r *Remote) UserPassword(flake, password string) (string, error) {
+	if resp, err := r.request(http.MethodPut,
+		populateField(api.UserPassword, "flake", flake),
+		strings.NewReader(password)); err != nil {
+		return "", err
+	} else {
+		var payload api.UserSecretPayload
+		err = unmarshal(resp.Body, &payload)
+		return payload.Secret, err
+	}
+}
+
+// UserSecret returns a user's secret.
+func (r *Remote) UserSecret(flake string) (string, error) {
+	var secret api.UserSecretPayload
+	err := r.fetch(http.MethodGet, populateField(api.UserSecret, "flake", flake), &secret, nil)
+	return secret.Secret, err
+}
+
+// UserSecretRegen regenerates a user's secret.
+func (r *Remote) UserSecretRegen(flake string) (string, error) {
+	var secret api.UserSecretPayload
+	err := r.fetch(http.MethodPut, populateField(api.UserSecret, "flake", flake), &secret, nil)
+	return secret.Secret, err
+}
+
+// UserImages returns a slice of snowflakes of a user's images.
+func (r *Remote) UserImages(flake string) ([]string, error) {
+	var flakes []string
+	err := r.fetch(http.MethodGet, populateField(api.UserImage, "flake", flake), &flakes, nil)
+	return flakes, err
+}