Temporary deck handoff
A temporary handoff moves an editable deck between an agent and a person's browser through an expiring link. It is optional server storage, separate from local authoring and from a live presentation room. The Hedy Deck 2.0 file format does not change.
Use Studio or the hedy2_handoff page tool for a project already saved in that browser. An agent with file and HTTP access can use the public upload protocol below. No account or Hedy administration connector is required.
What gets shared
The upload contains the editable JSON or ZIP package, including its assets, metadata, and speaker notes. Anyone with the complete open or download link can retrieve that snapshot until it expires or is revoked. The links are bearer capabilities; keep them out of public logs and unrelated messages. This is not end-to-end encrypted storage.
The temporary service accepts 1 byte to 25 MiB (26,214,400 bytes). This is a transfer limit; local V2 packages can still use their existing 128 MiB limit. The default lifetime is 24 hours from creation. HTTP callers may request a lifetime from 1 to 24 hours. Download the actual file if it must remain available longer.
A handoff is a snapshot. Later edits in either browser do not update it, and the link does not give access to anyone's local drafts, edit history, or live presentation controls. Uploading does not certify visual quality. The receiving browser verifies the file hash and parses the V2 source before import. Inspect its previews before presenting.
Browser and agent tool
Read the source project's current revision, then call:
const shared = await window.hedyDecks.tools.call("hedy2_handoff", {
id: "deck_your_source_id",
expectedRevision: 3,
idempotencyKey: "client-pitch-share-001"
});
if (!shared.ok) throw new Error(shared.error.message);
// Share shared.openUrl or shared.downloadUrl, and state shared.expiresAt.
Use the real id and revision returned by hedy2_read. The tool fails before uploading if that revision is no longer saved. It exports the captured revision without changing it. Only a completed upload returns openUrl, downloadUrl, and expiresAt; a started upload is not a successful handoff.
The same tool is in WebMCP and Studio's Agent command desk. A browser agent can select it, paste JSON arguments, and read its visible result without WebMCP. Calling this tool explicitly uploads the package; ordinary create, edit, render, and export operations remain local.
idempotencyKey is optional and contains 1–128 letters, digits, periods, underscores, colons, or hyphens. The browser retains its last 64 handoff retry records. Reuse it only to retry the same source revision and upload input in the same browser. This is a handoff retry key, separate from the local editing tools' requestId. On a source revision conflict, read again and decide which revision to share. Use a new key for a new snapshot.
Public HTTP protocol
Use the application's HTTPS origin, for example https://hedydeck.com. This API transfers complete files; it does not remotely edit Studio projects.
1. Generate three independent, cryptographically random 32-byte secrets, each encoded as 43-character unpadded base64url: readToken, manageToken, and requestId. Persist them with the exact request metadata before the first request so a lost response can be retried safely.
2. POST /api/handoffs with the JSON body below. A new pending handoff returns id, expiresAt, upload, and completeUrl. An already completed exact retry returns the ready handoff and links.
3. Send the exact file bytes to upload.uploadUrl using upload.method and every header in upload.headers. Do not substitute the Hedy API URL, add an account token, or encode the file as base64.
4. POST /api/handoffs/<id>/complete with {readToken,manageToken}. Share links only after this returns ready:true.
Creation request:
{
"title": "Quarterly review",
"filename": "quarterly-review.hedydeck",
"contentType": "application/zip",
"bytes": 12345,
"sha256": "<64 lowercase hex digits of the exact file bytes>",
"readToken": "<43-character random base64url secret>",
"manageToken": "<independent 43-character random base64url secret>",
"requestId": "<independent 43-character random base64url retry secret>",
"ttlSeconds": 86400
}
Use application/zip for a V2 .hedydeck package or application/json for complete monolithic V2 JSON. A JSON file cannot carry separately referenced binary assets. Titles contain 1–500 characters; filenames contain 1–120 safe characters, no directory path or control characters, and use .hedydeck or .json. The MIME value identifies the payload. bytes and sha256 describe the actual uploaded bytes, not their base64 or uncompressed form.
Completion verifies the stored byte length, MIME type, and SHA-256 digest against the reservation. Full V2 source parsing and rendering happen in the receiving browser. The service does not add or change deck fields.
Python: publish a file
This example uses only Python's standard library. Save it as publish_handoff.py and run python3 publish_handoff.py your-deck.hedydeck. It stores credentials in a private local checkpoint so rerunning after an uncertain response uses the same upload request. Do not publish that checkpoint. Use a different checkpoint path for a different file or handoff.
import hashlib, json, os, pathlib, secrets, sys
import urllib.error, urllib.request
origin = "https://hedydeck.com"
path = pathlib.Path(sys.argv[1])
checkpoint = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else str(path) + ".handoff-state.json")
content = path.read_bytes()
if not 1 <= len(content) <= 25 * 1024 * 1024:
raise SystemExit("Temporary handoffs accept 1 byte to 25 MiB.")
metadata = {
"title": path.stem[:500],
"filename": path.name,
"contentType": "application/json" if path.suffix == ".json" else "application/zip",
"bytes": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
"ttlSeconds": 86400,
}
if checkpoint.exists():
state = json.loads(checkpoint.read_text())
if state["origin"] != origin or any(state["create"][k] != v for k, v in metadata.items()):
raise SystemExit("Checkpoint belongs to different input; choose another checkpoint path.")
else:
state = {"origin": origin, "create": dict(metadata,
readToken=secrets.token_urlsafe(32),
manageToken=secrets.token_urlsafe(32),
requestId=secrets.token_urlsafe(32))}
fd = os.open(checkpoint, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as file:
json.dump(state, file)
def post(route, body):
request = urllib.request.Request(origin + route, json.dumps(body).encode(),
{"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=90) as response:
return json.load(response)
except urllib.error.HTTPError as error:
raise SystemExit(f"HTTP {error.code}: {error.read().decode()}")
ready = None
if not state.get("uploaded"):
created = post("/api/handoffs", state["create"])
if created.get("ready") is True:
ready = created
else:
state["id"] = created["id"]
checkpoint.write_text(json.dumps(state))
upload = created["upload"]
request = urllib.request.Request(upload["uploadUrl"], content,
upload["headers"], method=upload["method"])
with urllib.request.urlopen(request, timeout=180) as response:
response.read()
state["uploaded"] = True
checkpoint.write_text(json.dumps(state))
if ready is None:
ready = post("/api/handoffs/" + state["id"] + "/complete", {
"readToken": state["create"]["readToken"],
"manageToken": state["create"]["manageToken"],
})
if ready.get("ready") is not True:
raise SystemExit("Upload is not complete; do not share a link yet.")
for key in ("openUrl", "downloadUrl"):
if ready[key].startswith("/"):
ready[key] = origin + ready[key]
print(json.dumps({k: ready[k] for k in ("id", "openUrl", "downloadUrl", "expiresAt")}, indent=2))
A 409 UPLOAD_IN_PROGRESS response includes retryAfterSeconds: wait at least that long, then retry completion if the bytes were already uploaded. Rerun the Python example with the same checkpoint to resume its recorded phase. After an uncertain completion response, repeat completion with the same capabilities; do not upload again. A rate limit is a reason to wait, not to generate new credentials. A changed file needs a new checkpoint and new request secrets.
curl: upload and complete a prepared request
These commands use the checkpoint produced by the Python example. They also show the wire steps for an agent that generates its own metadata. python3 and curl are required. Keep the private checkpoint and the intermediate response files out of public artifacts.
umask 077
ORIGIN='https://hedydeck.com'
DECK_FILE='your-deck.hedydeck'
STATE_FILE='your-deck.hedydeck.handoff-state.json'
python3 - "$STATE_FILE" <<'PY' > handoff-create.json
import json, sys
print(json.dumps(json.load(open(sys.argv[1]))['create']))
PY
curl --fail-with-body --silent --show-error \
-H 'Content-Type: application/json' \
--data-binary @handoff-create.json \
"$ORIGIN/api/handoffs" > handoff-created.json
If handoff-created.json already contains ready:true, use its links and skip uploading. Otherwise, continue:
python3 - <<'PY' > handoff-upload.headers
import json
upload = json.load(open('handoff-created.json'))['upload']
for name, value in upload['headers'].items():
print(f'{name}: {value}')
PY
UPLOAD_URL=$(python3 -c "import json; print(json.load(open('handoff-created.json'))['upload']['uploadUrl'])")
UPLOAD_METHOD=$(python3 -c "import json; print(json.load(open('handoff-created.json'))['upload']['method'])")
HANDOFF_ID=$(python3 -c "import json; print(json.load(open('handoff-created.json'))['id'])")
curl --fail-with-body --silent --show-error \
--request "$UPLOAD_METHOD" --header @handoff-upload.headers \
--data-binary @"$DECK_FILE" "$UPLOAD_URL"
python3 - "$STATE_FILE" <<'PY' > handoff-complete.json
import json, sys
source = json.load(open(sys.argv[1]))['create']
print(json.dumps({key: source[key] for key in ('readToken', 'manageToken')}))
PY
curl --fail-with-body --silent --show-error \
-H 'Content-Type: application/json' --data-binary @handoff-complete.json \
"$ORIGIN/api/handoffs/$HANDOFF_ID/complete" > handoff-ready.json
Check handoff-ready.json for ready:true before returning its openUrl, downloadUrl, and expiresAt. Resolve leading-slash links against ORIGIN. Do not return uploadUrl: it is a short-lived upload credential, not a deck link.
Open, download, and revoke
| Action | Contract |
|---|---|
| Open in a browser | GET /open#<id>.<readToken>. The fragment supplies the capability to the loader; the browser does not send it with the initial page request. The loader retrieves and checks the file before local import. |
| Read ready metadata | GET /api/handoffs/<id> with x-hedy-handoff-token: <readToken>. |
| Download bytes | GET /api/handoffs/<id>/file with that header, or the returned downloadUrl carrying ?token=<readToken>. Follow its redirect to a short-lived storage URL. |
| Revoke | POST /api/handoffs/<id>/revoke with JSON { "manageToken": "<private management secret>" }. Retain this secret separately from the recipient's read link. |
For example, after reading the ready response:
DOWNLOAD_URL=$(python3 -c "import json; from urllib.parse import urljoin; print(urljoin('https://hedydeck.com', json.load(open('handoff-ready.json'))['downloadUrl']))")
curl --fail --location --silent --show-error \
"$DOWNLOAD_URL" --output received.hedydeck
To revoke a handoff created with the private checkpoint:
python3 - "$STATE_FILE" <<'PY' > handoff-revoke.json
import json, sys
print(json.dumps({'manageToken': json.load(open(sys.argv[1]))['create']['manageToken']}))
PY
curl --fail-with-body --silent --show-error \
-H 'Content-Type: application/json' --data-binary @handoff-revoke.json \
"$ORIGIN/api/handoffs/$HANDOFF_ID/revoke"
No new download URL is issued within the final 30 seconds before expiresAt. Access stops at the absolute expiration deadline; retrieving the file does not extend it. Revocation stops new retrievals. A storage URL issued just before revocation can remain valid for up to 60 seconds. Existing downloaded or locally imported copies cannot be recalled. Physical file cleanup is scheduled and may happen after access has already expired.
HTTP failures use {code,error}. Expect 403 for an invalid capability, 410 for an expired or revoked handoff (or 404 after its record is removed), 409 for an upload still in progress, and 429 for rate or quota limits. A 422 UPLOAD_MISMATCH means the stored file failed integrity checks and a new handoff is required. Creation is limited to 10 requests per minute per IP, with service-wide daily reservations of at most 100 new tickets and 256 MiB. Do not work around these limits by rotating keys.
Exact creation retries reuse all metadata and all three secrets. An omitted ttlSeconds is equivalent to 86400. Changing metadata under the same request ID is a conflict; retries do not extend expiration. An incomplete reservation keeps its original upload ticket for at most 10 minutes. Finish upload and completion within that window. Once that pending reservation expires with 410, start a new request with fresh secrets. If ticket preparation was interrupted before a ticket could be saved, the service may also require a new request after its preparation lease expires; it does not issue unbounded replacement tickets. Keep failed uploads' checkpoints while resolving uncertain network outcomes.
A ready creation replay returns the completed links: do not PUT again. A pending creation replay returns the original upload instructions, so repeating the same PUT bytes before completion is safe. If a completion response was lost, repeat /complete using the same capabilities; this recovers completion without creating another handoff.
The legacy CAPTCHA-protected, 2 MiB, 15-minute HTML dropbox is a different V1 feature. Do not send a V2 ZIP file to its endpoint. See agent handoff for local files and other supported delivery methods.