Skip to content

Getting Annotations Back

potato deploy pull myproject/config.yaml

Downloads everything a deployment has collected into a timestamped directory, checks what arrived, and prints the result. Nothing is deleted from the host.

Pulled to ./potato-pull-pilot-20260820-161905
  downloaded over HTTPS from https://potato-pilot.onrender.com
  project.sqlite snapshotted server-side with the SQLite backup API
  files      27
  size       3.6MB
  annotators 24
  databases  project.sqlite

What comes back

  • The whole annotation output directory: every annotator's user_state.json, their annotated instances, the assignment file, potato.log, and any adjudication or MACE output.
  • project.sqlite — memos, the codebook, cases, typing sessions, the review workflow. None of it can be regenerated from the annotations.
  • datasets.sqlite, when the deployment has one.

Two things are deliberately left behind. .item_cache.sqlite is rebuilt from the data files on demand and is often the largest file there. admin_api_key.txt is the credential guarding the endpoint doing the download.

The SQLite snapshot

project.sqlite runs in WAL mode with a live writer. The .sqlite file on disk is not a complete database on its own — the -wal sidecar holds committed pages it does not have. Copy the file alone and you get a database that is corrupt or missing recent work, and nothing tells you at the time. It opens. It just has holes.

So every transport snapshots through SQLite itself before copying, and if the snapshot fails, the pull says so rather than falling back to a file copy. The downloaded databases are then opened and integrity-checked locally, and a pull that returns an unreadable one is reported as a failure.

This is worth knowing if you ever fetch the files yourself:

# Wrong — silently gives you a damaged database
scp root@host:/opt/potato/app/project.sqlite .

# Right
ssh root@host "sqlite3 /opt/potato/app/project.sqlite \".backup /tmp/snap.sqlite\""
scp root@host:/tmp/snap.sqlite ./project.sqlite

Transports

Potato picks the best available transport for the provider.

Provider Transport
digitalocean, aws, aws-ec2, hetzner, vultr, linode, openstack SFTP over the deploy key, falling back to HTTPS
huggingface The backup Dataset; the Space itself for a --demo deployment
render, heroku, fly, railway, aws-ecs HTTPS
local docker cp

On the hosts with a disk that does not last, the backup holds the same data and can be downloaded even when the server is down.

A study that stores annotator state in MySQL is always pulled over HTTPS, on every provider. SFTP and docker cp would copy an output directory with no annotators in it; the archive writes each one out of the database as annotation_output/<user>/user_state.json. The pull stops with an error when the deployment's URL or admin key is missing.

The HTTPS route uses GET /admin/api/data/archive, authenticated with the admin API key that potato deploy generated and stored in .potato/secrets.json. It needs no shell, which is why it works on Render and anything serverless — the hosts most likely to lose their disk in the first place.

You can use it by hand against any deployment:

curl -H "X-API-Key: $ADMIN_KEY" \
  https://your-host/admin/api/data/archive -o annotations.tar.gz

# Or ask what it would send, before sending it
curl -H "X-API-Key: $ADMIN_KEY" https://your-host/admin/api/data/manifest

Verification and destroy

potato deploy destroy refuses to run on a deployment that has never been successfully pulled, so what counts as "successfully" matters.

A pull that returns zero files is not recorded as one. An empty result and a study nobody has annotated yet look identical from the outside, and only one of them means the data is safe. If the task genuinely has no annotations, say so:

potato deploy pull config.yaml --allow-empty

An unreadable database counts the same way. destroy keeps refusing until a good pull succeeds.

--force overrides destroy entirely, and discards whatever is on the host.

Options

Flag Purpose
--dest DIR Where to write, instead of ./potato-pull-<name>-<timestamp>/
--allow-empty Record a pull that returned nothing
--name Which deployment, when a config has several

Continuous backup

Pulling is something you remember to do. For a host whose filesystem does not survive a restart — a Space, a free Render instance — that is not good enough, so those deployments also mirror annotations to a HuggingFace Dataset every few minutes. It works on every provider despite being documented under HuggingFace Spaces.

Troubleshooting

"rejected the admin API key" — .potato/secrets.json no longer matches what the server has, usually because the deployment was recreated. On a provider with SSH you can read the key from /opt/potato/potato.env on the host.

"Nothing came back" — either nobody has annotated, or output_annotation_dir on the server is not where the config says. The manifest endpoint shows what the server thinks it has.

"No user_state.json anywhere in the result" — files arrived, so the transport worked. The output directory is the thing to check.

"has no admin data-archive endpoint" — the deployment is running a Potato older than the one that added it. Redeploy, or use the provider's own transport.