empti reads and writes your Empti drive from a terminal, with no drive mounted. It encrypts and decrypts on your computer with your vault's key, exactly as the app does, so Empti's service still never sees your files. Sign in once with empti login, unlock once with empti unlock, then use ls, cat, get, put, mv and rm like you would on any disk.
What it's for
When the drive is mounted, /Volumes/Empti is the easiest way in: any app, script or agent can read it like a folder. empti is for the times it isn't mounted, or when you'd rather not depend on it:
- Scripts and scheduled jobs that save reports, exports or backups straight to your drive.
- AI agents such as Claude Code and Codex, which can fetch a file with
empti get, work on it, and save the result withempti put. - A Linux server, or another Mac where the drive isn't running.
- S3 tools such as rclone, the AWS CLI and boto3, through a small S3 server that runs on your own computer (
empti serve s3). - Teams: inviting people, roles, private drives, comments and activity, all from the command line.
A change made with empti reaches your Macs like a change from one more Mac. It merges with their changes the same way, so neither side's edit is lost, and it usually shows up in Finder within one sync, about 30 seconds.
empti runs on macOS 14 or later and on Linux (x86_64 and ARM). The Mac app itself needs macOS 26 on Apple silicon. On Linux the commands are the same; you sign in with a code instead of a browser, and secrets are kept in files instead of the Keychain.
Install
empti isn't packaged yet: there's no installer, Homebrew formula or prebuilt download. For now you build it from the Empti source, on a Mac or for Linux.
On a Mac
Build it with Xcode or the Swift toolchain and copy it to any folder on your PATH (here ~/.local/bin):
# From the top of the Empti source
swift build -c release --package-path Packages/EmptiCore
mkdir -p ~/.local/bin && install -m 755 Packages/EmptiCore/.build/release/empti ~/.local/bin/empti
Check it works:
empti help
empti help, empti --help and empti -h all print the same list of commands. Adding --help to any command prints it too.
On Linux
Build it with Swift 6.4 or later, or in Docker with no Swift installed. From the top of the Empti source:
docker run --rm -v "$PWD":/repo -w /repo/Packages/EmptiCore swift:6.4-noble swift build -c release --static-swift-stdlib --scratch-path .build-linux
That puts Swift's runtime inside the binary, so the server needs only libcurl and libxml2. On Ubuntu 24.04 and Debian 13:
sudo apt-get install -y libcurl4t64 libxml2
sudo install -m 755 Packages/EmptiCore/.build-linux/release/empti /usr/local/bin/empti
- On Ubuntu 22.04 and Debian 12 the packages are
libcurl4andlibxml2, and you build with theswift:6.4-jammyimage (the binary's glibc must be no newer than the server's). - Build for the architecture you'll run on. On a Mac with Apple silicon, Docker builds for ARM (aarch64); add
--platform linux/amd64todocker runfor x86_64, which is slower under emulation. - On the server itself, a plain
swift build -c releaseinPackages/EmptiCoreworks too.
macOS treats a new build of empti as a different program, so the first time it asks whether it may use the "Empti CLI" Keychain items. Choose Always Allow. An agent or scheduled job has no way to answer that prompt, so run any empti command yourself once after each update (or sign in and unlock again).
Signing in and unlocking
Two steps, each once per computer: sign in to your account, then unlock your vault with its passphrase.
Sign in on a Mac, with a browser
empti login
This opens Google's sign-in in your browser. Use the same Google account you use in the app. The browser returns to empti on 127.0.0.1, so it must run on the same Mac, and you have five minutes to finish. --no-browser prints the link instead of opening it.
# Output
Signed in as [email protected]. Next: `empti unlock`.
Sign in with a code (Linux, servers, any account)
# On a Mac; on Linux, plain `empti login` does this
empti login --device
It prints a code and waits. Approve the code within 10 minutes from a device that's already signed in: in the Empti app, Settings → Account → Sign in a computer (type the code, check the computer's name and address, then Approve), or with empti approve on another computer where the CLI is signed in. This works for any account, including one made with Sign in with Apple.
# Output (on standard error)
To sign in, approve this code from a device that's signed in to Empti:
BDFH-KMNP
On a Mac with the empti CLI: `empti approve BDFH-KMNP --yes`. It expires in 10 minutes.
Waiting…
Signed in as [email protected]. Next: `empti unlock`.
Approving signs that computer in to your account. It still needs your passphrase to open any file, but don't approve a code someone else sent you.
--invite CODE, for signing up with an invite, needs the browser sign-in; a code signs in to an account that already exists.
empti approve CODE
empti approve CODE [--yes | --decline]
Lets another computer's sign-in code into your account, from a computer where the CLI is signed in. On its own it shows which computer is asking and changes nothing; --yes approves, --decline refuses. (empti approve EMAIL --workspace NAME is a different command: it adds someone to a team.)
empti approve BDFH-KMNP
# Output
"empti CLI on build-1" (from 203.0.113.7) asks to sign in to your Empti account. Run with --yes to approve, --decline to refuse; only approve a code you started yourself.
Unlock
empti unlock
# Output on a Mac
Vault passphrase:
Unlocked. The key is kept in your login Keychain (com.dylantl.Empti.cli) until `empti lock`.
empti unlock fetches the sealed copy of your vault that's kept in your account (the service can't open it), opens it with your passphrase here, and keeps the vault key until empti lock or empti logout: in your login Keychain on a Mac, in a file only you can read on Linux. The passphrase itself is never stored.
Without a terminal to type in
For scripts and jobs there are two ways to give the passphrase without a prompt:
--stdinreads it from the first line of standard input, for example from a password manager's command line tool. The key is then kept in the Keychain as usual.EMPTI_PASSPHRASEopens the vault for that one run and stores nothing. Every command run with it set works withoutempti unlock; the key exists only in that process's memory.
# Unlock once, from a password manager (replace with your own)
my-password-tool read "Empti passphrase" | empti unlock --stdin
# Or keep nothing on disk: the passphrase for this run only
EMPTI_PASSPHRASE="$(my-password-tool read 'Empti passphrase')" empti ls
With neither, and no terminal to ask in, empti stops with: no terminal to ask for the passphrase; use --stdin or EMPTI_PASSPHRASE.
Where secrets are kept
empti is a device of its own: it has its own session, separate from the app's, and its own copy of the vault key.
| What | Where |
|---|---|
| Session and vault key | On a Mac, your login Keychain, as generic passwords under the service com.dylantl.Empti.cli ("Empti CLI session", "Empti CLI vault-key") |
| Your identity key's local copy (teams) | On a Mac, the login Keychain, under com.dylantl.Empti.cli.identity |
On Linux, or a Mac with EMPTI_SECRETS=file | All of the above in $EMPTI_HOME/secrets/ instead, in files only you can read (mode 600, folder 700). On a Mac this is for SSH sessions and launchd jobs, which can't reach the login Keychain. |
| Everything else | $EMPTI_HOME: by default ~/Library/Application Support/empti-cli on a Mac, and $XDG_DATA_HOME/empti-cli (else ~/.local/share/empti-cli) on Linux. It holds this computer's device id (device.json), a lock file used while a session refreshes, the state of interrupted uploads (uploads/), and S3 access keys (s3-keys.json) |
Anyone who can read $EMPTI_HOME/secrets as you, or as root, can open your files until empti lock. On a shared server, skip empti unlock and pass EMPTI_PASSPHRASE per run instead, or give the server one folder rather than your whole vault.
Your Macs list the CLI's session as "empti CLI on computer name" under Settings → Account → Your Macs, where you can sign it out from another Mac. It stops working within the hour.
A new account
If your account has never been opened in Empti on a Mac, it has no vault yet. empti setup makes one, as the app's onboarding does. It asks for a new passphrase twice (at least 8 characters), keeps the sealed vault in your account, unlocks it here, and prints your recovery key once. It refuses if your account already has a vault.
# Output
New vault passphrase:
Again:
Vault made and unlocked. Your recovery key, shown once (keep it somewhere safe):
(your recovery key)
Locking and signing out
# Forget the vault key, stay signed in
empti lock
# End this CLI's session and forget the vault key
empti logout
# Who's signed in, and whether the vault is open
empti status
# Output
Signed in as [email protected]
Vault unlocked
How commands work
- Paths are paths on the drive, like
Projects/site/hero.png, with or without a leading/. Nothing, or/, is the top of the drive...isn't allowed. --drive NAMEpicks a drive (its name in any case, or its id fromempti drives). Without it, file commands use the main drive.--workspace NAMEpicks a team (its name or id fromempti workspaces). Without it, file and drive commands use your personal workspace.--jsonworks on every command: results go to standard output as JSON, and errors go to standard error as{"error": {"code": "…", "message": "…"}}. Put it after the command name.catalways writes the file's bytes.- Options take a value with a space or
=(--range 0-99or--range=0-99). An option a command doesn't take is an error, not silently ignored.--ends the options, for paths that start with-. - Progress for large transfers goes to standard error, only in a terminal, so it never mixes with output you pipe.
Reference: files
These take --drive NAME and --workspace NAME.
empti ls
empti ls [PATH] [-l] [-R]
Lists a folder (the top of the drive by default). Folders end in /. -l adds sizes and dates and shows full paths; -R lists everything below. A file's path lists just that file.
empti ls -l Projects/site
# Output
- 2026-11-02 09:14 Projects/site/drafts/
2.4 MB 2026-11-04 10:12 Projects/site/hero.png
18 KB 2026-11-04 10:15 Projects/site/notes.md
With --json: an array of {path, name, type, size, modified, modifiedBy, id}, where type is file or folder.
empti cat
empti cat PATH [--range START-END]
Writes a file to standard output, decrypting as it streams. --range works like HTTP ranges: 0-1023 (inclusive), 4096- (to the end) or -512 (the last 512 bytes). Only the parts of the file that hold the range are downloaded, so reading the start of a 50 GB video is quick.
empti cat Projects/site/notes.md
# What kind of file is it? Read only the first 64 KB
empti cat --range 0-65535 Videos/interview.mov | file -
empti get
empti get PATH [LOCAL]
Downloads a file. LOCAL is a file name or a folder to put it in; without it, the file is saved here under its own name. - writes it to standard output. The download goes to a hidden partial file first and is moved into place only when it's complete, so you never get half a file.
empti get Projects/site/hero.png
# Output: where it was saved, and its size
/Users/you/hero.png (2.4 MB)
With --json: {path, local, size}. get fetches one file; for a folder, see downloading a folder.
empti put
empti put LOCAL PATH
Uploads a file, encrypted on this Mac first. A PATH ending in /, or naming a folder, keeps the file's own name. Missing folders are made. A file already at PATH gets the new content as an edit to the same file. LOCAL - reads standard input (then PATH must include a file name). If an upload is interrupted, run the same command again: it carries on, sending only the parts that are missing.
empti put [email protected] Projects/site/
# Output: the path on the drive, and its size
Projects/site/[email protected] (2.4 MB)
# Save a command's output straight to the drive
some-report-generator | empti put - Reports/2026-11-04.md
With --json: the item, as in ls. put uploads one file; for a folder, see backing up a folder.
empti mkdir
empti mkdir PATH
Makes a folder and any missing folders above it. An existing folder is fine. Prints nothing; with --json, the folder.
empti mkdir Projects/site/exports
empti mv
empti mv FROM TO
Moves or renames. If TO is a folder (or ends in /), the item moves into it. It won't replace something that's already there. Prints nothing; with --json, the item in its new place.
empti mv Inbox/draft.md Projects/site/notes.md
empti rm
empti rm PATH [-r]
Removes a file, or a folder and everything in it with -r (without it, a folder must be empty). It goes to Recently deleted, as when you delete something on a Mac, and you can bring it back from the app. Prints nothing; with --json, {removed: [...]} listing every item removed.
empti rm -r Projects/site/drafts
Reference: drives
These take --workspace NAME; the private-drive commands need it.
empti drives
empti drives
The workspace's drives: the main drive first, then the others, with the drive each was forked from, and "(private)" on a team drive only some people can open.
# Output
Empti (main)
Client cut forked from Empti
With --json: an array of {id, name, main, forkedFrom, created}, plus private and restricting on private drives.
empti fork
empti fork DRIVE NAME
Makes a new drive called NAME with everything DRIVE has now, earlier versions and Recently deleted included. Only the file list is copied, so it takes a moment whatever the size. From then on the two drives change separately. Individual includes 3 drives in your personal workspace; a team's drives aren't limited.
empti fork Empti "Client cut"
empti drive delete
empti drive delete NAME
Moves a drive to Recently deleted. Its files are kept for 30 days.
# Output
Moved Client cut to Recently deleted. Its files are kept for 30 days.
empti drive access
empti drive access NAME --workspace W
empti drive access NAME --only a@x,b@y [--viewer c@z] --workspace W
empti drive access NAME --finish --workspace W
empti drive access NAME --rotate --workspace W
On its own, shows who can open a team drive: everyone in the team, or the people on a private drive and their roles (editor or viewer).
--only makes a team drive private to the people named and you (owners and admins). Everything in it is downloaded, re-encrypted with the drive's own key and uploaded again, then the old copies are deleted. Progress goes to standard error. On a drive that's already private, it adds the people named. The main drive can't be made private, and a private drive can't be made open again.
--finish carries on making a drive private after an interruption (running the same --only command again also works). --rotate changes a private drive's key, for an editor on it after someone was taken off by an admin who isn't.
empti drive access Finance --only [email protected],[email protected] --viewer [email protected] --workspace Acme
# Output (progress lines go to standard error)
Making Finance private: 1.2 of 8 GB (3 of 10 files)
…
Made Finance private. Everything in it was re-encrypted, and the old copies deleted.
Finance is private.
[email protected] editor
[email protected] editor
[email protected] viewer
[email protected] editor
empti drive remove-person
empti drive remove-person NAME EMAIL --workspace W
Takes someone off a private drive. Their access ends at once. If you're an editor on the drive, its key changes then; if not, an editor runs empti drive access NAME --rotate. Files they could open before stay readable to them if they kept copies.
empti drive remove-person Finance [email protected] --workspace Acme
Reference: teams
Team commands take --workspace NAME: a team's name (in any case) or its id. Inviting, approving, changing roles and removing people are for owners and admins.
empti workspaces
empti workspaces
Your personal workspace and every team you're in, with your role and how many people are in each.
# Output
Personal (personal)
Acme admin, 6 people
With --json: an array of {id, name, personal, role, members, keyVersion}.
empti workspace create, empti workspace delete
empti workspace create NAME
empti workspace delete NAME
create makes a team with you as its owner. delete deletes a team you own: its drives are kept for 30 days, then deleted.
# Output of: empti workspace create Acme
Made Acme. Invite people with `empti invite EMAIL --workspace "Acme"`.
empti invite
empti invite EMAIL --workspace NAME [--role viewer|member|admin] [--email]
Invites someone (as a member unless you give --role). By default it prints a link for you to send them yourself. The part after # carries the team's keys and never reaches Empti's service, so it needs no approval. With --email, Empti emails them a plain link instead, and you add them with a six-digit code when they accept (see approve). Either kind works for 7 days, and only for an account signed in with that email.
# Output
https://getempti.com/i/…#…
Send this link to [email protected] yourself. It works once, for 7 days, and needs no approval.
With --json: {link, email, role, workspace, kind, id} (link only for a link invite).
empti invites
empti invites --workspace NAME
Pending invites: sent, accepted and waiting for you (with their key fingerprint), or expired.
# Output
[email protected] member emailed accepted, waiting for you key 3F9A C210 77BE 04D1
[email protected] viewer link sent
empti resend
empti resend EMAIL --workspace NAME
Emails an invite again. It works for another 7 days, even if it had expired.
empti approve
empti approve EMAIL --workspace NAME [--code NNNNNN | --key FINGERPRINT]
Adds someone who accepted an emailed invite. Run it first without an option: your side of the code is sent. They then run empti join again (or open the link in Empti) and read you six digits; run it again with --code. A wrong code adds nobody. --key is the fallback: their whole key fingerprint, as they read it from their own Empti.
empti approve [email protected] --workspace Acme
empti approve [email protected] --workspace Acme --code 627189
empti decline
empti decline EMAIL --workspace NAME
Turns down someone who accepted an emailed invite. Their invite ends.
empti join
empti join LINK [--wait SECONDS]
Joins a team with the link you were sent. A link with a # part joins at once, after checking the team against what the link carries. An emailed link waits for an admin: once they've started, running it again shows the six-digit code to read to them. --wait keeps checking for that many seconds.
empti join "https://getempti.com/i/…" --wait 120
# Output, once the admin has sent their part
Your code for Acme: 627189
Read it to [email protected] (on a call, in person, or by message); they type it to add you. …
Joining by email also prints a team code and a history code to compare with the owner or an admin. Until you've compared the team code and recorded it with empti trust --checked-team-code, you can read, and new files wait on this Mac.
empti members
empti members --workspace NAME
Who's in a team, their role (owner, admin, member or viewer) and their key fingerprint.
# Output
[email protected] owner key 9C1E 44A0 B2D7 6F18
[email protected] member key 3F9A C210 77BE 04D1
empti role
empti role EMAIL viewer|member|admin --workspace NAME
Changes someone's role. A viewer can read every drive in the team but can't change files or drives: put, mkdir, mv, rm and fork are refused.
# Output
[email protected] is now a viewer in Acme.
empti remove
empti remove EMAIL --workspace NAME
Takes someone out of a team. Their access ends at once, and the team's key changes for everything saved from then on. Files they could open before stay readable to them if they kept copies. Link invites made before the key changed stop working; send new ones.
empti leave
empti leave --workspace NAME
Leaves a team. The owner can't leave.
empti trust
empti trust --workspace NAME [--drive NAME]
empti trust --workspace NAME --checked-team-code | --checked-history-code SEQ | --trust-service on|off
Shows what's waiting in a team and why, its team code and history code (with the position, such as "at change 41"), and any trouble with the team's signed history. The options record a check you made: that the team code matched, or that the history code at change SEQ matched the owner's or an admin's.
empti waiting
empti waiting --workspace NAME
Files you saved to a team while new content had to wait (for a confirmed key, a check, or the service) are kept on this Mac. This uploads any that can go now, and lists what still waits.
# Output
Uploaded Reports/q3.pdf (1.1 MB)
Waiting: Reports/q4.pdf (900 KB)
empti migrate, empti confirm
empti migrate --workspace NAME [--yes]
empti confirm --workspace NAME [--yes]
For a team made before signed history. migrate upgrades it (owner or admin): without --yes it shows who goes in, with roles and key fingerprints; with it, it writes the history. If an admin ran it, the owner then runs confirm to check and sign the members and roles. migrate also has options to redo an upgrade (--redo, --propose-abandon, --approve-abandon, --switch); empti help lists them.
empti resolve, approve-resolve, switch, records
empti resolve --workspace NAME --keep SEQ:HASH [--reason TEXT]
empti approve-resolve [REQUEST] --workspace NAME
empti switch --workspace NAME [--yes]
empti records [status | export FILE | restore FILE | new-keys --yes]
Repair tools you should rarely need, and only when empti trust tells you to. resolve and approve-resolve settle a team whose history has split in two (the owner, or a majority of admins). switch moves this Mac to the history the team kept. records exports, restores or replaces your identity records. These aren't in empti help yet.
Reference: comments and activity
Comments are on team drives only. They're sealed with the team's key and signed by you: Empti's service sees who commented and when, never the text.
empti comments
empti comments PATH --workspace NAME [--drive NAME]
A file's or folder's comments, oldest first, each with a short id. Marks the thread read.
# Output
[1f3a9c02] [email protected], 2 h ago: Can you check the March numbers?
[7be04d1a] [email protected], 1 h ago ↩ 1f3a9c02: Done. ✅ 1
empti comment
empti comment PATH "TEXT" --workspace NAME [--reply ID]
empti comment PATH --edit ID "TEXT" | --delete ID | --react EMOJI ID | --unreact EMOJI ID
Comments on a file (viewers can too). @email mentions someone; @team mentions everyone (owners and admins only). ID is the short id comments prints, or more of it. You can edit and delete your own comments; reactions are 👍 ✅ 👀 ❤️.
empti comment Finance/Budget.xlsx "@[email protected] can you check the March numbers?" --workspace Acme
empti activity
empti activity --workspace NAME [--drive NAME] [--since 1d]
What changed in a team and who changed it, grouped. --since takes 30m, 12h, 1d or 2w (one day by default). Changes made with empti show up here as yours, like a Mac's.
# Output
2 h ago [email protected] added 14 files to Photos/March
empti notifications
empti notifications [--read]
Mentions, replies and invites for you, with the file's path; • marks unread ones. --read marks them all read.
Reference: folder access for agents
Give one computer, such as a server running an agent, one folder of your drive and nothing else: keys that open only that folder's files, a list that names only what's in it, and a login the service lets reach only those files. It never gets your passphrase or vault. You manage it on your Mac in Settings → Agents (Give a folder…, Let in, Revoke) or with these commands, which need a signed-in, unlocked CLI. For now it covers folders in your personal drives, not team drives. The recipe shows the whole flow.
empti scope create
empti scope create PATH [--drive NAME] [--read-only] [--label TEXT] [--expires 30d] [--wait SECONDS] [--yes]
Makes folder access for PATH and prints a join token to run on the agent's computer within the hour. It then waits (10 minutes unless you give --wait) for that computer to join, shows its name and key, and asks whether to let it in. Say yes only if the key matches what empti agent join printed there. Once let in, the computer gets that folder's keys and nothing else. --read-only gives read access only; --label names the folder for the agent (the folder's own name by default); --expires ends the access after that long; --yes lets the computer in without asking.
empti scope create Projects/website --label website
# Output
Made folder access website (read-write) for Projects/website.
On the agent's computer, within the hour:
empti agent join EMPTI-JOIN-…
Waiting for it to join (Ctrl-C to stop; finish later with `empti scope pair website`)…
build-1 joined website with key 5D20 9AE1 C37F 0B64.
Does that key match what `empti agent join` printed on that computer? Let it in? [y/N] y
build-1 joined (key 5D20 9AE1 C37F 0B64); it can use website now. Keep its changes coming in with `empti scope sync --every 30` (or the app).
empti scopes
empti scopes
The folder access you've given: label, read-write or read-only, its state (waiting to join, active, revoked or expired) and the computer that joined, with its key fingerprint.
empti scope pair
empti scope pair ID [--yes]
Lets in the computer that joined, if scope create stopped waiting first. ID is the label or the start of the id.
empti scope sync
empti scope sync [ID] [--every SECONDS]
The agent's changes are sealed with the folder's keys, so a computer that holds your vault brings them into your drive, and brings your changes out to the agent. Empti on a Mac does this every 30 seconds while it runs. Without the app, run this, with --every 30 to keep going. Overwrites become earlier versions and deletes go to Recently deleted, so nothing an agent does is lost for good.
empti scope rotate
empti scope rotate ID
Gives the folder a new key version, for the same agent. Its changes so far are brought in first.
empti scope revoke
empti scope revoke ID
Cuts the agent off at once: the service refuses its next request. Its last changes are brought in first. Files it already read, it may have kept.
empti agent join
empti agent join TOKEN [--name NAME] [--wait SECONDS]
On the agent's computer: joins with the token. It needs no sign-in and no vault, and the computer's own key never leaves it. It prints that key, for you to compare, and waits (10 minutes by default) to be let in. After that, ls, cat, get, put, mkdir, mv and rm work on that folder as if it were the whole drive. --name is how the computer appears to you (its host name by default).
# Output
Joined. This computer's key: 5D20 9AE1 C37F 0B64
Waiting for the owner's Mac to grant the folder…
Access to website (read-write). Try `empti ls`.
empti agent, empti agent leave
empti agent
empti agent leave [--folder LABEL]
On the agent's computer: the folders it holds, and forgetting one. If it holds several, file commands take --folder LABEL to pick one. Leaving doesn't revoke the access; the owner does that with empti scope revoke.
Reference: S3 gateway
empti serve s3 makes your drives an S3 endpoint on this computer, so rclone, the AWS CLI, boto3 and anything else that speaks S3 can use them. Files are still decrypted and encrypted here; Empti's service sees only encrypted objects. It listens on 127.0.0.1 only, so other computers can't reach it. A bucket is a drive (the main drive is main, others by name, so Photos 2026 is photos-2026) and a key is a file's path.
empti s3 keys create
empti s3 keys create [--read-only] [--drive NAME | --folder PATH|LABEL] [--workspace NAME] [--label TEXT]
Makes an access key and prints its id and secret once, with an rclone remote and export AWS_… lines to paste. A key opens your personal workspace (or --workspace's), every drive or only --drive, read-write or --read-only. --folder serves one folder as one bucket from that folder's keys alone: on your Mac it first gives the gateway its own folder access, and on an agent's computer it names a folder the computer holds. Keys are kept only on this computer, in $EMPTI_HOME/s3-keys.json (mode 600).
empti s3 keys create --label rclone
# Output (shortened)
Made an access key (read-write, Personal, every drive, rclone).
The secret is shown only now; revoke the key with `empti s3 keys revoke …` if it leaks.
rclone (add to ~/.config/rclone/rclone.conf):
[empti]
type = s3
provider = Other
access_key_id = …
secret_access_key = …
endpoint = http://127.0.0.1:9000
force_path_style = true
no_check_bucket = true
AWS CLI, boto3 and the AWS SDKs:
export AWS_ACCESS_KEY_ID=…
export AWS_ENDPOINT_URL=http://127.0.0.1:9000
export AWS_REGION=us-east-1
export AWS_SECRET_ACCESS_KEY=…
empti s3 keys, empti s3 keys revoke
empti s3 keys
empti s3 keys revoke ID
Lists access keys (id, access, workspace, drive, label, when made), never their secrets. revoke deletes one; a running gateway stops accepting it at once.
empti serve s3
empti serve s3 [--port 9000] [--memory] [--quiet]
Serves your drives over S3 until Ctrl-C, printing one line per request (--quiet stops that). --memory serves an empty, throwaway bucket test instead, with no account needed, for trying a tool's settings. On an agent's computer it serves the folders it holds, without ever having your vault.
# Output
Serving S3 at http://127.0.0.1:9000, your drives as buckets. Ctrl-C stops it.
Access keys: `empti s3 keys`.
GET /main/Photos/a.jpg 206 1.2 MB 140 ms
Then, in another terminal:
rclone copy ~/Pictures/March empti:main/Photos/March --progress
aws s3 ls s3://main/Photos/
Deletes go to Recently deleted and an overwritten file keeps its old content as an earlier version, as on a Mac (versions aren't served over S3). Making or deleting buckets isn't supported: make drives with empti fork.
Reference: plan and billing
These need you signed in, but not unlocked.
empti plan
empti plan
Your plan (trial, renewal date, payment problems), storage used of your quota, drives of your limit, and the teams you own: seats bought and used, and their pooled storage.
# Output, during a trial
Empti, monthly: trial until 19 Nov 2026
Storage: 42 GB of 100 GB (42%)
Drives: 2 of 3
With --json: the billing status as Empti's service sends it.
empti upgrade
empti upgrade [--yearly] [--team NAME --seats N] [--no-browser]
Starts a plan on Stripe's checkout page, which it opens in your browser (--no-browser only prints the link). Individual by default, monthly unless --yearly. --team buys Teams for a team you own, with at least 2 seats and at least as many as the people in it. Your first plan starts with a 14-day trial, with up to 100 GB. Run empti plan afterwards to see it. See pricing.
empti upgrade --team Acme --seats 5 --yearly
empti billing
empti billing [--no-browser]
Opens Stripe's billing page: cards, invoices and cancelling. With --json, upgrade and billing print {url} and don't open a browser.
Recipes
Use it from Claude Code or Codex
Sign in and unlock yourself, once, in your own terminal. The agent never needs your passphrase; it just runs commands. Then tell it where things are, for example in CLAUDE.md or AGENTS.md:
Project files are on my Empti drive under Projects/site.
List them with `empti ls --json Projects/site`, read with `empti cat`,
download with `empti get`, and save results with `empti put`.
If empti exits with status 3, stop and ask me to run `empti login` or `empti unlock`.
A typical exchange then looks like this:
> Make a 2x version of the hero image
empti get Projects/site/hero.png
# …the agent makes [email protected]…
empti put [email protected] Projects/site/
Projects/site/[email protected] (2.4 MB)
In Claude Code you can allow the command so it doesn't ask each time, in .claude/settings.json. Allow only reading if that's all the agent needs:
{ "permissions": { "allow": ["Bash(empti ls:*)", "Bash(empti cat:*)", "Bash(empti get:*)"] } }
On a computer where you've run empti login and empti unlock, the CLI holds your whole vault key, so an agent that can run it there can read and change every file in your drives and teams. Leave rm and team commands out of its allow list unless it needs them. For an agent on a server, give it one folder instead.
Giving an agent one folder
An agent on a server can have one folder of your drive and nothing else. On your Mac, use Settings → Agents → Give a folder…, or:
empti scope create Projects/website --label website
It prints empti agent join EMPTI-JOIN-… and waits. On the server, with no empti login, no vault and no passphrase:
empti agent join EMPTI-JOIN-…
The server prints its key. Back on your Mac, check it matches and let it in (the prompt from scope create, or Let in under Settings → Agents). Then on the server the folder is the top of the drive:
empti ls -R
# Output
index.html
notes.md
The agent's changes reach your drive when a computer holding your vault brings them in. Empti on your Mac does that every 30 seconds while it runs, and Settings → Agents shows when each folder was last brought in. Without the app running:
empti scope sync --every 30
Nothing the agent does is lost for good: overwrites become earlier versions, deletes go to Recently deleted, and the agent's login can't delete stored files at all. To cut it off, use Revoke in Settings → Agents or:
empti scope revoke website
S3 tools on the server work the same way: empti s3 keys create there makes a key for the folder it holds, and empti serve s3 serves it as one bucket.
Back up a folder from a server
put uploads one file at a time, so loop over a folder with find. Each file keeps its path under the folder, and running it again resumes any upload that was interrupted.
# Upload every file in ~/Exports to Backups/exports on the drive
cd ~/Exports && find . -type f ! -name .DS_Store -print0 | while IFS= read -r -d '' f; do
empti put "$f" "Backups/exports/${f#./}" || exit 1
done
Sign the server in once with a code, which works over SSH:
# On Linux, plain `empti login` is the same
empti login --device
empti unlock
On a Linux server the secrets are files already. On a Mac reached over SSH or run from launchd, which can't use your login Keychain, set EMPTI_SECRETS=file for the sign-in and in the job's environment. If you'd rather not keep the vault key on disk, skip unlock and give the job EMPTI_PASSPHRASE. For a backup job that only needs one folder, give it that folder instead of your vault.
Download a folder
# Every file under Projects/site, into ./site
empti ls -R --json Projects/site \
| python3 -c 'import json,sys; [print(i["path"]) for i in json.load(sys.stdin) if i["type"]=="file"]' \
| while IFS= read -r p; do
mkdir -p "site/$(dirname "${p#Projects/site/}")"
empti get "$p" "site/${p#Projects/site/}" || exit 1
done
Work with a team drive
# Make a team and invite someone (prints a link to send them)
empti workspace create Acme
empti invite [email protected] --workspace Acme
# Files in a team work like your own, with --workspace
empti put report.pdf Reports/ --workspace Acme
empti ls Reports --workspace Acme
# A second drive in the team, private to two people
empti fork Empti Finance --workspace Acme
empti drive access Finance --only [email protected],[email protected] --workspace Acme
empti put budget.xlsx / --drive Finance --workspace Acme
# What changed this week
empti activity --workspace Acme --since 1w
Share links
Share links are made in the app for now: right-click a file or folder in the drive and choose Copy share link. empti has no share command yet.
Check your plan from a script
# Prints your plan; with --json, the full billing status
empti plan --json
# On a server, print the checkout link to open elsewhere
empti upgrade --yearly --no-browser
Exit codes
| Status | Meaning | JSON code |
|---|---|---|
| 0 | Done | None |
| 1 | Failed: anything else, such as "is a folder", "already exists", "folder isn't empty" or a refusal from the service | failed, is_a_folder, not_a_folder, exists, not_empty, bad_link, or the service's own code |
| 2 | Usage: an unknown command or option, or missing arguments | usage |
| 3 | Signed out or locked: run empti login or empti unlock | signed_out, locked |
| 4 | No such file, folder or team (a drive name that doesn't exist is 1) | not_found |
Errors go to standard error, as empti: message, or with --json as {"error": {"code": "…", "message": "…"}}. A script or agent that gets 3 should stop and ask a person, not retry.
Environment variables
| Variable | Does |
|---|---|
EMPTI_PASSPHRASE | Opens the vault for this run without empti unlock. Nothing is stored. |
EMPTI_SECRETS=file | On a Mac, keeps the session and keys in $EMPTI_HOME/secrets/ instead of the login Keychain. Always so on Linux. |
EMPTI_HOME | Where empti keeps its files. Default ~/Library/Application Support/empti-cli on a Mac, ~/.local/share/empti-cli on Linux. Two folders act as two separate devices. |
EMPTI_API | The service address, for development (for example http://127.0.0.1:8787). Default https://api.getempti.com. |
Troubleshooting
"Not signed in. Run `empti login`."
The session ended: you ran empti logout, or this CLI was signed out from Settings → Account on a Mac. Run empti login, then empti unlock.
"Your vault is locked."
Run empti unlock, or set EMPTI_PASSPHRASE for the run.
"That passphrase doesn't open your vault."
Use the passphrase you use in the app. If you changed it on a Mac, use the new one.
"Your account has no vault yet."
Set one up in Empti on a Mac, or run empti setup.
"the Keychain didn't hand over the session" (or the vault-key)
On a Mac, usually a new build of empti. Run any command yourself in a terminal and choose Always Allow, or sign in and unlock again. Over SSH or in a launchd job, use EMPTI_SECRETS=file.
"no terminal to ask for the passphrase"
An agent or script ran empti unlock. Unlock yourself in a terminal, or use --stdin or EMPTI_PASSPHRASE.
Browser sign-in doesn't work here
The browser sign-in needs a browser on the same Mac, because Google sends you back to 127.0.0.1 there, and it's Google only. On a server, over SSH, or for an account made with Sign in with Apple, use empti login --device (plain empti login on Linux) and approve the code in Settings → Account → Sign in a computer, or with empti approve CODE. The code expires after 10 minutes; run login again for a new one.
"This computer has access to one folder only"
The computer joined with empti agent join and has no vault, so commands that need it say so there. File commands and empti serve s3 work on the folder it holds.
"Your file list is still in the old format."
Open Empti on a Mac once to update the drive, then try again.
"You can view Acme but not change it"
You're a viewer in that team. An owner or admin can change your role with empti role.
A file I uploaded isn't in Finder yet
Your Macs pick up changes on their next sync, usually within about 30 seconds. Likewise, a file added on a Mac appears in empti ls once that Mac has finished uploading it.
A file called "name (empti CLI).ext" appeared
The CLI and a Mac changed the same file at the same moment. Both versions are kept: the Mac keeps the other one under that name, so nothing is lost.
Still stuck
Email [email protected] with the command you ran and what it printed (leave out any passphrase, token or S3 secret).