Skip to content

Server mode

clipboarder can run as a shared HTTP backend. Multiple clients connect with bearer tokens; each token is scoped to its own namespace, and namespaces are fully isolated — even the FTS index honors them.

Run it on your laptop for cross-device sync, or on a small server for a team shared clipboard.

Quick start

# 1. Create your first token (binds it to a namespace; namespace is created on first use)
clipboarder admin token create --namespace alice --label "Alice's MacBook"
# → prints tk_XXXX… — copy it now, you'll set it on the client below

# 2. Start the server
clipboarder serve --bind 127.0.0.1:7474

# 3. Point a client at it
export CLIPBOARDER_SERVER='http://127.0.0.1:7474'
export CLIPBOARDER_TOKEN='tk_XXXX…'

# Once the transparent-remote-store client lands, every `cb` command will
# automatically route through the server. Until then, use the REST API
# directly (see below).

Configuration

Default config path: ~/Library/Application Support/com.clipboarder.app/server.toml. Override with --config.

Generated by admin token create:

bind = "127.0.0.1:7474"

[[tokens]]
fingerprint = "tk_aB3xQ9XY"
hash = "$argon2id$v=19$m=19456,t=2,p=1$…"   # argon2id PHC string
namespace = "alice"
label = "Alice's MacBook"
created_at = 1731436800000
last_used_at = 1731437452000

[[tokens]]
fingerprint = "tk_yZ1mN4LP"
hash = "$argon2id$v=19$m=19456,t=2,p=1$…"
namespace = "admin"
label = "Web UI"
admin = true

Tokens are argon2id-hashed at rest — the plaintext bearer is only shown once (when you run admin token create). The fingerprint is the first 11 chars of the bearer (tk_ + 8 random) and is the stable ID for admin token list / admin token revoke. The file is also chmod 600.

Live config reload

admin token revoke rewrites server.toml atomically. A running clipboarder serve polls the file's mtime every 2 s — within a couple of seconds the revoked token starts returning 401 from a live server, with no restart. Same goes for adding new tokens via admin token create.

REST endpoints

All routes are under /v1 and require Authorization: Bearer tk_…. The namespace is derived from the token — there's no namespace param in any URL.

Method Path Body Returns
GET /v1/health ok (no auth required)
GET /v1/whoami {namespace, label}
GET /v1/items?q&kind&limit array of items
POST /v1/items {content, kind?, meta?, source_app?} {id, inserted, kind}
GET /v1/items/{id} item
GET /v1/items/{id}/image raw PNG bytes (image-kind items)
DELETE /v1/items/{id} 204
POST /v1/items/{id}/pin {id, pinned: true}
DELETE /v1/items/{id}/pin {id, pinned: false}
POST /v1/clear 204 (non-pinned only)
GET /v1/stats {total, pinned, by_kind, namespace}
GET /v1/watch Server-Sent Events stream of new items

Query parameters

  • q — FTS5 query (prefix-matched). Omit for "most recent".
  • kindtext / url / repo / code / color / image / file / pdf / music / video / email / pinned / all (default all).
  • limit — max items returned. Capped at 10,000.

SSE — /v1/watch

Streams every new item captured under the caller's namespace, encoded as a JSON data: payload on an event: item line. Pings every 15 s as event: ping to keep proxies happy.

curl -N -H "Authorization: Bearer $CLIPBOARDER_TOKEN" "$CLIPBOARDER_SERVER/v1/watch"

Admin

# Create a token
clipboarder admin token create --namespace alice --label "Alice's Mac"

# List tokens (full values redacted to prefix)
clipboarder admin token list
clipboarder admin token list --json

# Revoke by fingerprint (the 11-char `tk_…` shown in `list`) or full bearer
clipboarder admin token revoke tk_aB3xQ9XY

Revocation rewrites server.toml atomically and the running clipboarder serve picks it up within ~2 s via mtime polling — no restart needed.

Web admin console

The server bundles a single-page admin console at /admin for managing tokens and namespaces from a browser.

# 1. Mint an admin token (these gate /admin and /v1/admin/*).
clipboarder admin token create --admin --label "web ui"
# → tk_XXXXXXXXXXX… — copy it

# 2. Visit the server in a browser.
open http://127.0.0.1:7474/admin
# Paste the token to unlock.

What you can do from the UI:

  • See every namespace with item count, pinned count, token count, and last activity
  • See every token with fingerprint, namespace, label, last-used time, admin flag
  • Create new tokens (the plaintext bearer is shown exactly once — copy it then)
  • Revoke any token by clicking Revoke

The page is served from an embedded HTML asset — no separate web build, no JS framework, no static-file dependencies. The same clipboarder serve binary that runs the REST API serves the console.

JSON API for the same surface (each endpoint requires a token with admin = true):

Method Path Body Returns
GET /v1/admin/tokens array of {fingerprint, namespace, label, created_at, last_used_at, admin}
POST /v1/admin/tokens {namespace, label?, admin?} {bearer, fingerprint, namespace, label, created_at, admin} — bearer plaintext is only returned here
DELETE /v1/admin/tokens/{fp} 204 / 404
GET /v1/admin/namespaces array of {namespace, items, pinned, last_activity, token_count}

A non-admin bearer gets 403 on every admin endpoint (vs 401 for no/invalid token), so clients can distinguish "log in" from "not authorized."

Deployment

The server doesn't terminate TLS itself. Run it bound to 127.0.0.1 and front it with one of:

Caddy

clipboarder.example.com {
  reverse_proxy 127.0.0.1:7474
}

That's it — Caddy handles Let's Encrypt automatically.

Nginx

server {
  listen 443 ssl http2;
  server_name clipboarder.example.com;
  # SSL config…

  location / {
    proxy_pass http://127.0.0.1:7474;
    proxy_http_version 1.1;
    # For /v1/watch SSE
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 86400s;
  }
}

systemd unit (Linux)

[Unit]
Description=clipboarder server
After=network.target

[Service]
ExecStart=/usr/local/bin/clipboarder serve --bind 127.0.0.1:7474
Restart=always
RestartSec=5
User=clipboarder

[Install]
WantedBy=multi-user.target

launchd (macOS)

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.clipboarder.server</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/clipboarder</string>
    <string>serve</string>
    <string>--bind</string>
    <string>127.0.0.1:7474</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
</dict>
</plist>

Save to ~/Library/LaunchAgents/com.clipboarder.server.plist and run launchctl load ….

Namespace isolation

Items, FTS index, pin state, stats — everything is scoped by namespace. The same content copied from two clients in different namespaces produces two distinct rows. The unique index on (namespace, content_hash) enforces this at the schema level.

Verified by scripts/test-server.sh — 27 assertions in CI:

  • alice + bob both post → alice sees 2 items, bob sees 1
  • alice searches "anthropic" → 1 hit
  • bob searches "anthropic" → 0 hits (even though the bytes exist, they're in alice's namespace)
  • alice DELETE /v1/items/{bob_id} → 404
  • alice POST /v1/clear → bob's items untouched

Watch (real-time SSE)

cb watch consumes the server's /v1/watch Server-Sent Events stream and prints each new item as a JSON line. End-to-end latency is ~5 ms in practice — no polling, no missed events, no per-tick database scans.

# Tail every new item in your namespace as JSON Lines:
cb watch

# Filter to one kind:
cb watch --kind url

If you'd rather speak the wire protocol directly (e.g. from a language without a clipboarder client), the endpoint is:

curl -N -H "Authorization: Bearer $CLIPBOARDER_TOKEN" "$CLIPBOARDER_SERVER/v1/watch"

Each new item is delivered as event: item with a JSON data: payload. Keep-alive event: ping is emitted every 15 s so reverse proxies don't drop the connection.

Roadmap

That's a wrap on the original v0.1 server roadmap. Future work (no specific plan yet): per-item TTLs, cross-namespace search for admin tokens, S3-backed image storage, signed-cookie auth for the web UI.