Handoff: File Sharing for AI Agents
Private file storage and controlled share links for AI agents, with no human signup.
Handoff: private file storage and controlled sharing for AI agents. Save a file, get a share link with expiry, open limits, and approval. REST + MCP.
- Remote MCP:
https://hand-off.space/mcp(Streamable HTTP) - REST:
https://hand-off.space/v1 - Registry name:
io.github.jueluddinsf/handoff - Canonical origin:
https://hand-off.space
The published Replit host https://handoff-agent-file-sharing.replit.app serves the same API. Share links use PUBLIC_BASE_URL when that secret is set, and otherwise the host that received the create request, so links keep working before DNS for https://hand-off.space is live. CANONICAL_REDIRECT=true 301s GET and HEAD from a replit.app host to the canonical origin. Leave it unset until https://hand-off.space answers. Health probes are not redirected.
Quickstart: REST (3 calls)
BASE=https://hand-off.space
# 1. Sign up. The key is shown once, so store it.
curl -s -X POST $BASE/v1/signup -H 'content-type: application/json' -d '{"agent_id":"my-agent"}'
export HANDOFF_API_KEY=hf_...
# 2. Save a file privately (base64 bytes)
curl -s -X POST $BASE/v1/files -H "authorization: Bearer $HANDOFF_API_KEY" -H 'content-type: application/json' \
-d '{"name":"report.md","mime":"text/markdown","content_base64":"IyBRMyByZXBvcnQ="}'
# 3. Create a share link. Save url.
curl -s -X POST $BASE/v1/files/FILE_ID/grants -H "authorization: Bearer $HANDOFF_API_KEY" -H 'content-type: application/json' \
-d '{"audience":"anyone_with_link","expires_in_hours":24,"max_opens":3}'
Give the url to the recipient. It downloads with a plain GET. A bare Authorization: $HANDOFF_API_KEY or X-Handoff-Api-Key: $HANDOFF_API_KEY is accepted everywhere a Bearer key is.
Quickstart: MCP (3 tool calls)
- Connect to
https://hand-off.space/mcp.initializeandtools/listdo not need a key. - Call
signup. Saveapi_key. Send it on later calls. - Call
save_file, thencreate_share_link, and hand over the returnedurl.
Authentication
The key is a secret shown once. Any of these work on REST and on /mcp:
Authorization: Bearer <api_key>Authorization: <api_key>with no Bearer prefixX-Handoff-Api-Key: <api_key>- On MCP tools other than
signupandfetch_shared_file, anapi_keyargument is used only when no Authorization or X-Handoff-Api-Key header is present.
If a header is present, that header is the only credential. The argument is not a fallback for a bad header.
Tools
| Tool | What it does |
|---|---|
signup | Create an account and return api_key once. No key required. The tool name is signup. |
save_file | Store a private file: name, mime, content_base64. Returns the file id. No link yet. |
list_files | List this account's files, newest first. |
create_share_link | file_id, audience (anyone_with_link or agent), optional audience_value, expires_in_hours, max_opens, requires_approval. Returns grant_id, url, token, and token_prefix once. Save the url. |
get_share_status | Read approval, expiry, revocation, and open_count. Does not return the url and does not use an open. |
approve_share | Approve an owned grant that is waiting because requires_approval was set. |
deny_share | Deny an owned grant. A denied grant stays blocked. |
revoke_share | Permanently revoke an owned grant. |
get_access_log | Latest opens and refusals for an owned grant. |
fetch_shared_file | Download by share url or token. Public links need no key. An agent audience needs that agent's key. Small files return inline. |
fetch_shared_file follows the same rules as GET on the share url, including an agent audience checked with the caller's key. Files up to 1048576 bytes return content_base64, and content_text when the type is text. A larger file returns an error that names the size and the GET url, and does not consume an open. A successful inline return does.
Share rules
anyone_with_link— possession of the url is authorization, within expiry andmax_opens.agent—audience_valueis the recipient's registeredagent_id. That agent sends its own key. Any other caller gets401with errorunauthorized.requires_approval: true— opens return403with errorpending_approvaluntil the owner approves. Deny leaves the grant blocked.- Revoke is permanent. The file itself stays until the owner deletes it.
- The url and token are returned only by the create response. Storage keeps a SHA-256 hash.
GET /v1/grants/:idandget_share_statusreturntoken_prefix(8 hex characters), which is not enough to download.
Agent to agent
curl -L -H "authorization: Bearer $AGENT_B_KEY" -o out.bin "$URL"
Approval
curl -X POST -H "authorization: Bearer $HANDOFF_API_KEY" $BASE/v1/grants/GRANT_ID/approve curl -X POST -H "authorization: Bearer $HANDOFF_API_KEY" $BASE/v1/grants/GRANT_ID/deny
The same actions are MCP tools approve_share and deny_share.
Download responses
| Status | Error | Meaning |
|---|---|---|
| 410 | expired | The link is past its expiry. |
| 429 | limit | The link hit its max opens, or the account hit its daily open cap. |
| 403 | revoked, denied, pending_approval | Revoked, denied, or still waiting for approval. |
| 401 | unauthorized | The link is for a different agent. |
Limits
- Default account tier: 100 MB total, 50 files, 10 MB per file, 200 successful opens per UTC day.
- Allowed types on that tier: txt, md, csv, json, pdf, png, jpg, jpeg, gif, webp, svg, xml, yaml, yml, log, docx, xlsx, pptx. Executables, scripts, HTML, and archives are refused.
- Signup: 60 per IP each 3600 seconds, and 2000 across all addresses each 86400 seconds. Change them with
SIGNUP_IP_LIMIT,SIGNUP_IP_WINDOW_SECONDS,SIGNUP_GLOBAL_LIMIT, andSIGNUP_GLOBAL_WINDOW_SECONDS. - Other request limits: 300 per IP per minute, 120 per key per minute, 60 download attempts per share token per minute.
Client configuration
Replace YOUR_HANDOFF_API_KEY with the key from signup. You can connect and list tools with no key, then add the header before calling other tools.
Claude Code
claude mcp add --transport http handoff https://hand-off.space/mcp \ --header "Authorization: Bearer YOUR_HANDOFF_API_KEY"
{ "mcpServers": { "handoff": { "type": "http", "url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${HANDOFF_API_KEY}" } } } }
Claude Desktop
{
"mcpServers": {
"handoff": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://hand-off.space/mcp", "--header", "Authorization:${HANDOFF_AUTH}"],
"env": { "HANDOFF_AUTH": "Bearer YOUR_HANDOFF_API_KEY" }
}
}
}
Cursor
{
"mcpServers": {
"handoff": {
"url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${env:HANDOFF_API_KEY}" }
}
}
}
VS Code / GitHub Copilot
{
"inputs": [
{ "type": "promptString", "id": "handoff_api_key", "description": "Handoff API key", "password": true }
],
"servers": {
"handoff": {
"type": "http",
"url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${input:handoff_api_key}" }
}
}
}
Cline
{
"mcpServers": {
"handoff": {
"type": "streamableHttp",
"url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer YOUR_HANDOFF_API_KEY" },
"disabled": false,
"autoApprove": []
}
}
}
Windsurf
{
"mcpServers": {
"handoff": {
"serverUrl": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${env:HANDOFF_API_KEY}" }
}
}
}
OpenAI Agents SDK
async with MCPServerStreamableHttp(
name="handoff",
params={"url": "https://hand-off.space/mcp", "headers": {"Authorization": f"Bearer {os.environ['HANDOFF_API_KEY']}"}},
cache_tools_list=True,
) as handoff:
...
LangChain / LangGraph
client = MultiServerMCPClient({
"handoff": {
"transport": "streamable_http",
"url": "https://hand-off.space/mcp",
"headers": {"Authorization": f"Bearer {os.environ['HANDOFF_API_KEY']}"},
}
})
Any other MCP client
| URL | https://hand-off.space/mcp |
| Transport | Streamable HTTP, POST only |
| Header | Authorization: Bearer <api_key>, the bare key, or X-Handoff-Api-Key |
Security
- Files are private until a grant is created.
- Downloads are attachments with
Content-Type: application/octet-stream,nosniff, andContent-Security-Policy: sandbox. - API keys and share tokens are stored as SHA-256 hashes.
- Every download attempt is logged, including refusals.