← Back to Amber

Automated backups

Copy your memories to storage you control, on a schedule

You can already export everything at any time by asking your assistant. This page is for the other case: an unattended job (a weekly cron, a scheduled task, a CI workflow) that copies your memories somewhere you control without an AI session running.

Amber never pushes to your storage, and never asks for your cloud credentials. You pull. That way a compromise of Amber cannot reach your bucket, and Amber holds nothing of yours beyond the memories you chose to store.

1. Create a backup key

Ask your AI assistant to create one:

Create an Amber backup key for me

It calls amber_create_backup_key and shows you a key beginning with amber_bk_. It is shown once. Amber stores only a hash of it, so it cannot be recovered, only replaced. Save it somewhere you keep secrets.

2. Save it and start one backup

First save the key to a file. The scheduled job below reads it from there, and it keeps the key out of your shell history:

mkdir -p ~/.amber && umask 077
printf '%s' 'PASTE_YOUR_KEY_HERE' > ~/.amber/backup-key
chmod 600 ~/.amber/backup-key

A backup is a task, not a single download. You start it, poll until it says complete, then fetch the file:

# start it -- returns immediately with a task id
curl -fsS -X POST -H "X-Amber-Backup-Key: $(cat ~/.amber/backup-key)" \
  https://next-api.ambermem.com/backup

# check on it
curl -fsS -H "X-Amber-Backup-Key: $(cat ~/.amber/backup-key)" \
  https://next-api.ambermem.com/backup/PASTE_THE_TASK_ID

Once status is complete the reply carries a download_url, signed and good for 7 days, which is long enough that an interrupted download can be resumed rather than restarted. Treat it as a password: anyone holding it can download your memories until it expires, and it cannot be revoked. Poll again any time for a fresh one.

The download supports resuming. If a transfer drops, curl -C - against the same URL continues from where it stopped instead of starting again.

Why a task and not one download? Amber runs on Cloudflare Workers, and a single request there has a hard budget of outbound calls: enough for a few tens of thousands of memories, and no more. Rather than give you a backup that works until your account grows and then quietly starts failing, the export is built in slices across background runs. There is no size limit at all, and the cost is one poll loop.

3. Put it on a schedule

Use a small script rather than a one-line cron entry. Two reasons, and both bite in practice: a shell variable assigned on the same line as the command is expanded before the assignment applies, so the key would be sent empty; and cron treats % as a newline, which truncates any command containing a date format.

~/bin/amber-backup.sh (chmod +x ~/bin/amber-backup.sh)

#!/usr/bin/env bash
set -euo pipefail

KEY="$(cat "$HOME/.amber/backup-key")"
API="https://next-api.ambermem.com"
DEST="$HOME/backups/amber"
mkdir -p "$DEST"
OUT="$DEST/amber-$(date +%F).json.gz"

# 1. Start the task. -f makes an HTTP error a non-zero exit instead of a saved error page.
START="$(curl -fsS --retry 3 --retry-delay 30 -X POST \
  -H "X-Amber-Backup-Key: $KEY" "$API/backup")"
TASK="$(printf '%s' "$START" | grep -o '"task_id":"[^"]*"' | cut -d'"' -f4)"
[ -n "$TASK" ] || { echo "ERROR: no task id in: $START" >&2; exit 1; }
# (with jq installed:  TASK=$(printf '%s' "$START" | jq -r .task_id)  )

# 2. Wait for it. A large account takes several background passes.
STATUS=''
for _ in $(seq 1 120); do
  STATUS="$(curl -fsS -H "X-Amber-Backup-Key: $KEY" "$API/backup/$TASK")"
  case "$STATUS" in
    *'"status":"complete"'*) break ;;
    *'"status":"failed"'*)   echo "ERROR: backup failed: $STATUS" >&2; exit 1 ;;
  esac
  sleep 5
done

URL="$(printf '%s' "$STATUS" | grep -o '"download_url":"[^"]*"' | cut -d'"' -f4)"
[ -n "$URL" ] || { echo 'ERROR: timed out waiting for the backup' >&2; exit 1; }

# 3. Download it.
curl -fsSL --retry 3 --retry-delay 10 --max-time 900 "$URL" -o "$OUT.part"

# Verify the archive before keeping it. A half-written file is the one failure a backup cannot
# afford, and it takes TWO checks, not one. gzip catches corruption -- but the archive is a
# series of gzip members, so a file cut exactly at a member boundary is still structurally
# valid gzip and passes -t while the JSON inside it is missing its end.
if ! gzip -t "$OUT.part"; then
  echo 'ERROR: downloaded archive is corrupt - not saved' >&2
  rm -f "$OUT.part"
  exit 1
fi

# So check the document really ends. The download itself declares its length, which is what
# makes curl above fail rather than save a short file; this catches a truncation that happened
# anywhere else -- a full disk while copying it, a sync that stopped halfway.
if [ "$(gzip -dc "$OUT.part" | tail -c 2)" != ']}' ]; then
  echo 'ERROR: archive is incomplete (no closing marker) - not saved' >&2
  rm -f "$OUT.part"
  exit 1
fi

mv "$OUT.part" "$OUT"
echo "OK: $OUT"

Then schedule it for 04:00 every Sunday (crontab -e):

0 4 * * 0 /home/YOU/bin/amber-backup.sh >> /home/YOU/backups/amber/backup.log 2>&1

Sending it to S3

Download first, then upload. Do not pipe curl straight into aws: a pipeline reports the last command's exit status, so a download that dies halfway uploads a truncated file and the job still reports success, which is exactly what -f was there to prevent.

# ...after the integrity check above, in the same script:
aws s3 cp "$OUT" "s3://your-bucket/amber/$(date +%F).json.gz"

Windows

%USERPROFILE%\bin\amber-backup.ps1, registered with Task Scheduler:

$ErrorActionPreference = 'Stop'
$key  = (Get-Content "$HOME\.amber\backup-key" -Raw).Trim()
$api  = 'https://next-api.ambermem.com'
$dest = "$HOME\backups\amber"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$out  = Join-Path $dest ("amber-{0}.json.gz" -f (Get-Date -Format yyyy-MM-dd))
$hdr  = @{ 'X-Amber-Backup-Key' = $key }

# 1. Start the task.
$task = (Invoke-RestMethod -Method Post -Uri "$api/backup" -Headers $hdr).task_id

# 2. Wait for it.
$status = $null
foreach ($i in 1..120) {
  $status = Invoke-RestMethod -Uri "$api/backup/$task" -Headers $hdr
  if ($status.status -eq 'complete') { break }
  if ($status.status -eq 'failed') { throw "Backup failed: $($status.error)" }
  Start-Sleep -Seconds 5
}
if ($status.status -ne 'complete') { throw 'Timed out waiting for the backup' }

# 3. Download it.
Invoke-WebRequest -Uri $status.download_url -OutFile "$out.part"

# A zero-length archive means the download never completed.
if ((Get-Item "$out.part").Length -eq 0) {
  Remove-Item "$out.part"
  throw 'Downloaded archive is empty - not saved'
}
Move-Item "$out.part" $out -Force

What you get

A JSON file: every memory with its content, subjects, topics, metadata and dates, plus anything still in the trash. Deletion is reversible for 30 days, so a backup that dropped it would destroy something you can still restore.

{
  "exported_at": "2026-08-28T04:00:00.000Z",
  "memories": [
    {
      "id": "...",
      "content": "...",
      "subjects": ["..."],
      "topics": ["..."],
      "metadata": {},
      "created_at": "..."
    }
  ]
}

The download declares its size, so a connection that dies halfway makes curl fail rather than quietly leave you a shorter file. That matters more than it sounds: the archive is a series of gzip members, so a file cut at exactly the wrong place is still valid gzip, and gzip -t would pass it. The script above therefore also checks the document ends.

The file is gzipped. Read it with gunzip amber-backup.json.gz, or inspect it in place with zcat / gzcat. Compressed, a memory is roughly a tenth of its JSON size.

Limits and responses

EndpointStatusMeaning
POST /backup202Started. The body carries task_id and status_url
POST /backup429Too soon. One backup per 60 minutes; honour Retry-After
GET /backup/<id>200status is pending, running, complete or failed. Polling is free and never counts against the rate limit
GET /backup/<id>404No such task, or it is older than 7 days
either401Missing, unknown, or revoked key
either500Something failed on our side. Retry, because a failed start does not consume your rate limit

A finished backup and its file are kept for 7 days, then deleted. Take the copy you want to keep; the one on our side is a handover, not an archive.

Is the export ever partial?

No, and there is no size limit. The export is built in slices and compressed as it goes, so nothing is bounded by what fits in one request or in memory. Earlier versions stopped at 100,000 memories and set a truncated flag; both are gone.

If a slice cannot be completed, the task goes to failed with a reason rather than sitting on running forever, so the script above exits non-zero and writes no file. A partial backup you trust is worse than an obvious failure, and a task that never finishes is worse than either, because nothing ever tells you to look.

Does the key expire?

No. A backup key does not expire and has no TTL. It works until you replace or revoke it. That is deliberate: a key that quietly stopped working would leave a weekly job failing silently, which is the failure a backup can least afford.

If your backups stop, Amber tells you

A backup that stops does not announce itself: the job gets an error, writes it to a log nobody reads, and you find out on the day you need it. So Amber watches two things. If something presents a key you replaced or revoked, it says so on your next conversation with your assistant, naming the date you replaced it. And if no backup has succeeded for twice as long as you said they run, it says that too, whatever the cause: a dead key, a machine that is off, a disabled schedule, a failing script.

You decide where that second line sits. When you create the key, tell your assistant how long a gap should count as a problem and it passes warn_after_days. The number is used exactly as given: 10 means you hear about it once nothing has succeeded for ten days. Leave room for a missed run, so a nightly job is comfortable around 3 and the weekly script above around 14. Without a number Amber waits 30 days, which is a long time if you back up every night. The warning appears at most once a day and stops on its own as soon as a backup succeeds.

Putting it back

The simplest restore is one you never upload at all. If your backup already lives somewhere reachable over HTTPS (S3, R2, Backblaze, Google Cloud Storage, your own MinIO), just give Amber the link:

curl -fsS -X POST -H "X-Amber-Backup-Key: $(cat ~/.amber/backup-key)"   -H "Content-Type: application/json"   -d '{"url":"https://your-bucket.example/amber-backup.json.gz"}'   https://next-api.ambermem.com/import/from-url

You can also just ask your assistant to do it. amber_import_memories takes the same URL.

Amber never copies the file. It reads the archive from your URL a piece at a time as it works, and keeps nothing. Two consequences worth knowing: the link has to stay valid until the restore finishes, which can take hours on a large account, so use one good for at least 30 days. The host also has to support range requests, which every major object store does.

If the file is only on your computer

Then there is nothing for Amber to read, so it has to be uploaded. The easy way is the restore page. Ask your assistant to restore a local file and it hands you a link that opens it ready to go, with no key to paste: the link carries a one-off upload permission rather than a credential, so whatever key your backup job holds is left alone. Drop the file in, then tell your assistant, and it starts the restore. The same uploader also appears inside the chat if your client renders panels. Opening the page directly still works: it asks for your backup key instead.

By hand, it is two calls. Ask for an upload link and PUT the file straight to storage, and it never passes through Amber:

KEY="$(cat ~/.amber/backup-key)"
UP="$(curl -fsS -X POST -H "X-Amber-Backup-Key: $KEY" https://next-api.ambermem.com/import/presign)"

# The reply carries upload_url and complete_url.
curl -fsS -X PUT --upload-file amber-backup.json.gz "PASTE_upload_url"
curl -fsS -X POST -H "X-Amber-Backup-Key: $KEY" "PASTE_complete_url"

The upload link is good for 7 days, so an interrupted upload can be retried rather than restarted.

Either way, a restore is a task: the reply carries a task_id and a status_url you poll until it says complete.

It cannot duplicate your memories. Every memory keeps the id it was exported under, and a restore skips any id already present, so running it twice, or restoring a backup that overlaps what is already there, changes nothing the second time. A restore that stops part-way resumes instead of starting over. Trash comes back as trash, not as live memories.

Anything already in your account is left alone. A restore adds what is missing; it is not a rollback, and there is no "replace everything" mode, because that would be a way to lose memories, and nothing here is allowed to do that.

If a key leaks

Ask your assistant to revoke it (amber_revoke_backup_key), or simply create a new one. There is one key per account, so creating replaces the old one and the old one stops working immediately.

A backup key can do exactly two things: download an export, and restore one. It cannot read individual memories, browse your account, or touch your subscription.

Restoring is more than adding, though, and it is worth being straight about it. An archive records which memories were in the trash, and a restore puts those back as trash so that deleting something is not quietly undone. That means somebody holding your key could restore a doctored archive and move memories you still have into the trash. Nothing is destroyed and the trash keeps everything for 30 days, so it is recoverable, but it is a change rather than only an addition. Revoke a key you no longer trust; it takes one step.

Why not have Amber push to my bucket?

Because that would mean Amber storing your cloud credentials. That is a much larger thing to lose than a download key, and it would contradict the point of the product: we collect as little as possible about you. Pulling keeps your credentials yours.

Back to Amber · Privacy · Report a problem