How it works
Every dev machine logs in once with stift login. That starts a
background service which watches your agents' session directories and syncs
them to the server, and restores sessions other machines have pushed. The
server is either Stift Cloud (hosted) or a single MIT binary you
run yourself. Same engine, same client.
Zero runtime dependencies (pure Go stdlib), no database server — sessions are
stored as tar.gz blobs plus JSON metadata on disk. The same binary is both
server (stift serve) and client (stift push / pull / …).
Supported agents
| Agent | Name | What gets synced |
|---|---|---|
| Claude Code | claude | ~/.claude/projects/<project>/<session>.jsonl + todo state |
| OpenAI Codex CLI | codex | ~/.codex/sessions/…/rollout-*.jsonl |
| Gemini CLI | gemini | ~/.gemini/tmp/<project>/ (logs, saved chats, checkpoints) |
| Cursor CLI | cursor | ~/.cursor/chats/<project>/<session>/ |
| opencode | opencode | session + messages + parts from ~/.local/share/opencode/storage |
| aider | aider | .aider.chat.history.md, .aider.input.history (in-project) |
Anything not listed can be added as a custom agent — a name and a path pattern in a small JSON file.
stift login to changes.
Stift Cloud
Don't want to run a server? Stift Cloud is the same open-source
engine, hosted for you on sync.stift.sh. Sign up, create a token in
the dashboard, and point the CLI at it — sessions sync across your machines and
your whole team. No infra, no upgrades, backups handled.
Quickstart
# 1. install the CLI
$ curl -fsSL https://stift.sh/install.sh | sh
# 2. sign up at app.stift.sh, create a token, then log in
# (this also starts background auto-sync)
$ stift login https://sync.stift.sh --token stf_...
# 3. that's it — sessions sync automatically and show up in your dashboard.
# push by hand any time you want:
$ stift push
Pricing
- Unlimited sessions & storage
- Your server, your data
- Single binary, no database
- Community support
- Up to 100 sessions
- Uncapped storage
- Web dashboard
- Zero infrastructure
- Unlimited sessions
- Uncapped storage
- Team organizations
- Per-seat billing, upgrade anytime
Storage is uncapped on every plan. Start on the free tier and upgrade in the dashboard when you outgrow it — billing is handled by Stripe.
Install the client
$ curl -fsSL https://stift.sh/install.sh | sh
The script (read it first) detects your OS/arch,
verifies the binary's SHA-256 checksum, and installs to
/usr/local/bin if writable, otherwise ~/.local/bin.
| Override | Meaning |
|---|---|
STIFT_VERSION | version to install (default latest) |
STIFT_INSTALL_DIR | where to put the binary |
STIFT_BASE_URL | download base (default https://stift.sh/dl) |
Windows: download stift-windows-amd64.exe
and put it on PATH. Or build from source with make build (Go 1.26+).
Direct downloads
| Platform | Binary | Checksum |
|---|---|---|
| Linux x86-64 | stift-linux-amd64 | .sha256 |
| Linux arm64 | stift-linux-arm64 | .sha256 |
| macOS Intel | stift-darwin-amd64 | .sha256 |
| macOS Apple Silicon | stift-darwin-arm64 | .sha256 |
| Windows x86-64 | stift-windows-amd64.exe | .sha256 |
Self-host a server
Binary
$ stift serve --data /var/lib/stift
On first start the server prints an admin token once — store it.
Then it listens on :8580. For a permanent install on a Linux host
(bare metal, VM, or LXC), use the hardened systemd unit from
deploy/stift.service:
$ cp dist/stift-linux-amd64 /usr/local/bin/stift && chmod +x /usr/local/bin/stift
$ cp deploy/stift.service /etc/systemd/system/
$ systemctl enable --now stift
$ journalctl -u stift # first-boot admin token is in here
Proxmox VE
One command on the Proxmox host creates an unprivileged Debian LXC
(1 core / 512 MB / 8 GB by default), installs stift as a hardened systemd
service, and prints the server URL + admin token, ready for stift login:
$ bash -c "$(curl -fsSL https://stift.sh/proxmox.sh)"
The script (read it first) shows its plan and
asks for confirmation before creating anything. Flags:
--ctid, --hostname, --storage, --disk,
--cores, --memory, --bridge,
--ip CIDR --gw IP (static instead of DHCP),
--binary PATH (install a local build instead of downloading),
--token, --yes. See --help.
Docker
$ docker compose up -d # uses docker-compose.yml in the repo
$ docker compose logs stift # grab the first-boot admin token
Or pin the token instead of fishing it out of logs:
$ export STIFT_ADMIN_TOKEN="stf_$(openssl rand -hex 24)"
$ docker run -d -p 8580:8580 -v stift-data:/data \
-e STIFT_ADMIN_TOKEN stift:latest
A read-only web UI for browsing and downloading sessions is served at /
(paste a token; it never leaves your browser).
Background sync
stift login also starts a small background service, so you set up
a machine once and then forget about it. A lightweight daemon — a per-user
systemd/launchd service, or a detached process where neither exists — runs
every ~30s and keeps everything in sync. No manual push/pull.
$ stift login https://sync.stift.sh --token stf_...
background auto-sync started — your sessions now sync automatically
- Pushes every changed agent session across all your projects, continuously.
- Pulls sessions for projects you're actively working on here, restoring them so the local agent sees them — never overwriting a live local file (conflicts are logged, not applied).
- Projects are matched across machines by git repo name (the remote's last path segment, else the folder name), so the same repo lines up even when its path differs machine to machine.
On a second machine
Point stift at a folder and it pulls that project's history right away; later sessions keep syncing automatically.
$ cd ~/code/myapp
$ stift link
linked ~/code/myapp -> myapp
pulled 12 session(s)
Managing the service
| Command | What it does |
|---|---|
stift start · stop · restart | control the background sync service |
stift status | running state + any sessions on the server not restored here yet |
stift link · unlink · links | manage which folders pull which project |
stift pull --project-id NAME | restore a whole project into the current directory |
stift login --no-daemon | log in without starting background sync |
Push & pull
# one-time, on each machine
$ stift login https://sessions.example.com --token stf_...
# see what agents/sessions exist for the current project
$ stift agents
# push this project's sessions (all agents) to the server
$ stift push
# ...or be specific
$ stift push --agent claude,codex --latest
$ stift push --all-projects # everything on this machine
# on another machine: browse and restore
$ stift list
$ stift pull 1920b89e # by id (prefixes work)
$ stift pull --latest --agent claude
- Pushes are idempotent — re-pushing an unchanged session is a no-op (
unchanged); a changed session updates the existing record in place (updated). - Pulls never overwrite existing local files unless you pass
--force;--dry-runlists the archive contents first. - Sessions restore to the same project path they came from — agents key their session storage by project path, so this is what makes the agent see them.
Skills & agent config
# push this machine's skills, agents, commands and CLAUDE.md
$ stift push --skills
# on another machine
$ stift pull --skills
$ stift skills list
# every unit has its own history
$ stift skills history skills/deploy
$ stift skills diff skills/deploy
$ stift skills rollback skills/deploy 3
# admins: share a set with the whole team
$ stift push --skills --scope org
- One unit, one version. Each skill, agent, command and CLAUDE.md is versioned on its own — rolling one back never touches another.
- Three scopes.
user(~/.claude/…),project(.claude/…) andorg— written by admins, pulled by everyone into~/.stift/organd linked in. - No surprises. A push on top of a version you haven't pulled is rejected;
settings*.json,.mcp.jsonand.envfiles are never synced. - Stored as content-addressed files plus a small JSON manifest per version — no database, works the same self-hosted and on Stift Cloud.
Custom agents
Define it in ~/.config/stift/agents.json (override the path with STIFT_AGENTS):
[
{ "name": "windsurf", "sessions": "~/.windsurf/runs/*" },
{ "name": "roo", "sessions": "~/.roo/{md5}/tasks/*" },
{ "name": "notes", "sessions": ".ai-notes/history.md" }
]
Two fields per agent:
name— lowercase letters, digits, dashes; must not clash with a built-in. Usable everywhere an agent name is (stift push --agent roo).sessions— a glob pattern saying where sessions live. Each match becomes one session: a matched file is a single-file session, a matched directory is a session containing everything under it. The session id is derived from the matched name (run-7,history, …).
Pattern rules
~/…patterns are home-based; anything else resolves against the project directory (like aider's in-project history files).- Many agents encode the project path into a directory name. Placeholders cover the common encodings and make project filtering work exactly like built-ins:
{sha256}(Gemini-style hash),{md5}(Cursor-style),{munged}(Claude-style-work-app),{basename}(last path element). - A home-based pattern without a placeholder is treated as machine-global: detected on every push, stored without a project association.
- Invalid entries are skipped with a warning; nothing outside your home (or project) directory is ever archived, even if a pattern tries.
Tokens & environment
$ stift token create laptop # prints the secret once
$ stift token create ci --admin # admin = may manage tokens
$ stift token list
$ stift token revoke <id>
| Variable | Used by | Meaning |
|---|---|---|
STIFT_SERVER, STIFT_TOKEN | client | override saved login (handy for CI) |
STIFT_CONFIG | client | config file path (default ~/.config/stift/config.json) |
STIFT_SYNC_INTERVAL | daemon | background sync interval (default 30s) |
STIFT_HOST | client/daemon | override this machine's host label (default OS hostname) |
STIFT_SKILLS_STATE | client | skills sync state (default ~/.config/stift/state.json) |
STIFT_STATE | daemon | sync-state cache path (default ~/.cache/stift/sync-state.json) |
STIFT_LISTEN, STIFT_DATA | server | listen address / data directory |
STIFT_ADMIN_TOKEN | server | register a fixed admin token at startup |
HTTP API
Authorization: Bearer <token>| Method & path | Description |
|---|---|
| POST /v1/sessions | upload (multipart: meta JSON field, then archive tar.gz) |
| GET /v1/sessions?agent=&project=&host=&q= | list, newest first |
| GET /v1/sessions/{id} | metadata (id prefixes accepted) |
| GET /v1/sessions/{id}/archive | download tar.gz |
| DELETE /v1/sessions/{id} | delete |
| POST /v1/blobs/check · PUT/GET /v1/blobs/{sha} | content-addressed files for skills |
| GET /v1/bundles?scope=&agent=&project=&name= | list skill units (current versions) |
| PUT/GET/DELETE /v1/bundles/{scope}/{agent}/{name} | one unit; ?version=, ?history=1, ?force=1; 409 when stale |
| GET /v1/whoami | token name + role |
| GET/POST/DELETE /v1/tokens | token management (admin only) |
| GET /healthz | liveness (no auth) |
Security notes
- Tokens are stored hashed (SHA-256) on the server; secrets are shown once.
- Run behind TLS — a reverse proxy (Caddy, nginx, Traefik) or your tunnel of choice. The server itself speaks plain HTTP.
- Tar extraction rejects absolute paths and
..traversal.