Handoff: File Sharing for AI Agents
Private file storage for AI agents with expiring, revocable, approval-gated share links and access logs.
Hosted MCP Server
npx add-mcp 'https://hand-off.space/mcp'Installs into Claude Code, Codex, Cursor and more
Documentation
Handoff: File Sharing for AI Agents
Send a file from an AI agent to a user or another agent with a controlled share link: expiry, max opens, human approval, agent-only audience, revoke and access log. Remote MCP server + REST API, no human signup.
Handoff gives an agent a safe way to hand a file to someone else, either a human or another agent. One call (share_file over MCP, or POST /v1/share) returns a download link, with no API key needed. For more control, the agent saves the file privately, then creates a share link with the rules it needs: an expiry, a maximum number of opens, an optional human approval step, and a specific recipient agent. It can check status, revoke, and read the access log at any time.
- Remote MCP server:
https://hand-off.space/mcp(Streamable HTTP) - REST API:
https://hand-off.space/v1(OpenAPI, llms.txt) - Auth: none for the first call.
share_file/POST /v1/sharewithout a key creates an account and returns itsapi_keyonce. Later calls sendAuthorization: Bearer <api_key>(or passapi_keyas a tool argument when the client can't set headers). You can also get a key on its own withsignup/POST /v1/signup. No email, card or human account is needed. - 14 MCP tools:
share_file,signup,save_file,list_files,create_share_link,get_share_status,approve_share,deny_share,revoke_share,get_access_log,fetch_shared_file,delete_file,list_shares,update_share.initializeandtools/listneed no key. - Registry name:
io.github.jueluddinsf/handoff
This repository holds docs and listing metadata. The service is hosted, so you have nothing to install.
Use it when an agent needs to…
- Deliver a report to a human. The agent saves
report.pdf, then sends a link that expires in 24 hours and opens at most 3 times. - Hand a file to another agent. The link works only when the named recipient agent presents its own key.
- Wait for a human before releasing a file. The link stays locked (
pending_approval) until the owner approves it. - Take something back. Revoke a link, and see every successful and refused open.
Common questions an agent asks
- How can my agent send a file (PDF, CSV, report, image) to the user? Call
share_filewithnameandcontent_text(orcontent_base64), orPOST /v1/share, and reply with the returnedurl. The user opens it in a browser. No account is needed to download. - How do I get a temporary or expiring download link? Links from
share_fileexpire in 7 days by default. Setexpires_in_hoursandmax_opensonshare_fileorcreate_share_link. After that the link returns410 expiredor429 limit. - How do I transfer a file from one agent to another? Use
audience: "agent"with the recipient'sagent_id. Only that agent's key can open it, throughfetch_shared_fileor a plainGET. - Can a human approve before the file is released? Yes. Set
requires_approval: true. Opens are blocked untilapprove_shareis called. - Can I take a link back or see who opened it? Yes. Use
revoke_shareandget_access_log. Refused opens are logged too. - Is it a public file host or a paste bin? No. Files are private by default and every link is an explicit grant you control.
Guides
- How an AI agent can send a file to a user: send a PDF, CSV or report as a link
- Temporary download link for AI agents: expiring, open-limited and revocable links
- Agent-to-agent file transfer over MCP: a link only one named agent can open
- Human approval before an agent releases a file: links stay locked until you approve
Quickstart: one call
REST, raw bytes. The response is the URL, plus api_key: hf_… on a second line when the call created an account:
curl -s --data-binary @report.pdf "https://hand-off.space/v1/share?name=report.pdf&format=url"
# https://hand-off.space/g/…
# api_key: hf_…
REST, JSON:
curl -s -X POST https://hand-off.space/v1/share -H 'content-type: application/json' \
-d '{"name":"notes.md","content_text":"# Notes","expires_in_hours":24,"max_opens":3}'
# → {"url":"https://hand-off.space/g/…","expires_at":"…","max_opens":3,"requires_approval":false,
# "file_id":"…","grant_id":"…","token_prefix":"…","next":{…},"api_key":"hf_…"}
MCP. Connect to https://hand-off.space/mcp with no auth, then call:
{"name": "share_file", "arguments": {"name": "notes.md", "content_text": "# Notes"}}
The result has url, expires_at (7 days by default), grant_id, file_id and, for a new account, api_key once. Give the url to the recipient, who downloads with a plain GET. Save the api_key: you need it to revoke the link, check its status or read the access log. Send it as Authorization: Bearer <api_key> (or as the api_key argument) on later calls, so new files stay in the same account.
share_file and POST /v1/share also take expires_in_hours, max_opens, audience (anyone_with_link or agent), audience_value, requires_approval, mime and an optional ref label. With raw bytes, pass them as query parameters.
More control: REST (3 calls)
Use this when you want to store a file once and create several links for it, each with its own rules.
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"}'
# → {"account_id":"…","api_key":"hf_…","agent_id":"my-agent","caps":{…},"next":…}
export HANDOFF_API_KEY=hf_...
# 2. Save a file privately (content_text, or content_base64 for binary)
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_text":"# Q3 report"}'
# → {"id":"FILE_ID","name":"report.md",…}
# 3. Create a share link
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}'
# → {"grant_id":"…","url":"https://hand-off.space/g/…","token":"…","token_prefix":"…"}
More control: MCP (3 tool calls)
- Connect your client to
https://hand-off.space/mcp(configs below). - Get a key with the
signupMCP tool (no key needed) orPOST /v1/signup. Put it in your client'sAuthorization: Bearer …header and reconnect, or passapi_keyon each call. - Call
save_file→create_share_link, then hand over the returnedurl.
Tools
| Tool | What it does |
|---|---|
share_file | One call. name plus content_text or content_base64. Optional mime, expires_in_hours (default 168), max_opens, audience, audience_value, requires_approval, ref. Returns url, expires_at, grant_id, file_id, and api_key once if no key was sent. No key needed. |
signup | Create an account and return an API key once. No key needed. Optional agent_id. |
save_file | Save a file privately: name, content_text or content_base64, optional mime. Returns the file id. No link is created. |
list_files | List your files, newest first. |
create_share_link | file_id, audience (anyone_with_link or agent), optional audience_value (the recipient's agent_id), expires_in_hours (default 168, 7 days), max_opens, requires_approval. Returns grant_id, url (shown once), expires_at and max_opens. |
get_share_status | Approval, expiry, revocation and open_count for a link, without using up an open. |
approve_share | Approve a link created with requires_approval. Opens stay blocked until this succeeds. |
deny_share | Deny a link that is waiting for approval. It stays blocked. |
revoke_share | Permanently revoke a link. The file is kept. |
get_access_log | Up to the latest 1,000 opens and refusals for a link. |
fetch_shared_file | Download a file from a Handoff share url or token (honors expiry, open limits, approval and agent audience). Files up to 1 MB come back inline as content_base64 (plus content_text for text); larger files return an error telling you to GET the url. |
delete_file | Delete a file you own. The bytes are removed and existing share links stop working. |
list_shares | List links you own, optionally for one file_id: grant_id, status, open_count, max_opens, expires_at, audience. No tokens or URLs. |
update_share | Set a link's expiry: grant_id, expires_in_hours measured from now (e.g. 168 for 7 days). |
Every tool except share_file, signup and fetch_shared_file (on an anyone_with_link share) needs your API key. share_file with no key creates a new account.
Client configuration
You can connect with no header at all and call share_file, then save the api_key it returns. To add the key, replace YOUR_HANDOFF_API_KEY in the configs below with your key, or use the environment-variable form shown.
Claude Code
claude mcp add --transport http handoff https://hand-off.space/mcp \
--header "Authorization: Bearer YOUR_HANDOFF_API_KEY"
You can also commit a project-scoped .mcp.json, which is the file in this repo. Claude Code expands ${HANDOFF_API_KEY} from your environment:
{ "mcpServers": { "handoff": { "type": "http", "url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${HANDOFF_API_KEY}" } } } }
Claude Desktop
Claude Desktop custom connectors don't take a static header, so bridge with mcp-remote in claude_desktop_config.json:
{
"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 (~/.cursor/mcp.json or .cursor/mcp.json)
{
"mcpServers": {
"handoff": {
"url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${env:HANDOFF_API_KEY}" }
}
}
}
VS Code / GitHub Copilot (.vscode/mcp.json)
{
"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 (cline_mcp_settings.json)
Set "type": "streamableHttp" explicitly. If you leave it out, Cline falls back to legacy SSE.
{
"mcpServers": {
"handoff": {
"type": "streamableHttp",
"url": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer YOUR_HANDOFF_API_KEY" },
"disabled": false,
"autoApprove": []
}
}
}
Windsurf (~/.codeium/windsurf/mcp_config.json)
{
"mcpServers": {
"handoff": {
"serverUrl": "https://hand-off.space/mcp",
"headers": { "Authorization": "Bearer ${env:HANDOFF_API_KEY}" }
}
}
}
OpenAI Agents SDK (Python)
import asyncio, os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp
async def main():
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:
agent = Agent(
name="Reporter",
instructions="Write the report, save it with Handoff, and reply with a share link that expires in 24h.",
mcp_servers=[handoff],
)
print((await Runner.run(agent, "Summarize Q3 sales into report.md and share it.")).final_output)
asyncio.run(main())
LangChain / LangGraph (langchain-mcp-adapters)
import os
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.agents import create_agent
client = MultiServerMCPClient({
"handoff": {
"transport": "streamable_http",
"url": "https://hand-off.space/mcp",
"headers": {"Authorization": f"Bearer {os.environ['HANDOFF_API_KEY']}"},
}
})
tools = await client.get_tools()
agent = create_agent("openai:gpt-4.1", tools) # or langgraph.prebuilt.create_react_agent
Hermes Agent (~/.hermes/config.yaml)
mcp_servers:
handoff:
url: "https://hand-off.space/mcp"
headers:
Authorization: "Bearer YOUR_HANDOFF_API_KEY"
OpenClaw (~/.openclaw/openclaw.json)
Set transport explicitly, because OpenClaw defaults to SSE. Leave headers out to start with share_file.
{ "mcp": { "servers": { "handoff": {
"url": "https://hand-off.space/mcp",
"transport": "streamable-http",
"headers": { "Authorization": "Bearer ${HANDOFF_API_KEY}" } } } } }
Any other MCP client
| Setting | Value |
|---|---|
| URL | https://hand-off.space/mcp |
| Transport | Streamable HTTP (stateless, POST only) |
| Header | Authorization: Bearer <api_key> |
More step-by-step setup for agents is in llms-install.md.
Agent Skill (no MCP needed)
This repo also ships an Agent Skill that teaches any skills-capable agent (Claude Code, Codex, Copilot, Cursor and others) to use the REST API:
npx skills add jueluddinsf/handoff-mcp
Use with Grok Bot
Add Handoff as a custom MCP connector:
| Setting | Value |
|---|---|
| URL | https://hand-off.space/mcp |
| Header | none to start; later Authorization: Bearer <api_key> |
The bot can call share_file right away, with no key. Save the api_key it returns (shown once), then put it in the header and reconnect, or pass it as the api_key tool argument.
Or have the bot follow skills/handoff/SKILL.md and call the REST API over plain HTTP.
Example prompts:
- Save this report to Handoff and send me a link that expires in 24h.
- Share this file with my other agent only.
- Show me who opened the link, then revoke it.
Recipes
Agent → human report, in one call
share_file(name="report.md", content_text=…, expires_in_hours=24, max_opens=3) → send the url.
Agent → human report, with an expiry (3-step)
save_file(report.pdf) → create_share_link(audience="anyone_with_link", expires_in_hours=24, max_opens=3) → send the url. Use get_access_log to confirm it was opened.
Agent → agent handoff
Agent A calls share_file(name=…, content_text=…, audience="agent", audience_value="agent-b"), or create_share_link(audience="agent", audience_value="agent-b") for a saved file. Agent B downloads with its own key:
curl -L -H "authorization: Bearer $AGENT_B_KEY" -o out.bin "$URL"
Over MCP, Agent B can call fetch_shared_file(url_or_token=URL) instead. Any other caller gets 401 {"error":"unauthorized",…}.
Human-approval gate
share_file(..., requires_approval=true) or create_share_link(..., requires_approval=true). Until the owner approves, opens return 403 {"error":"pending_approval",…}. To approve or deny, call the approve_share / deny_share MCP tools, or:
curl -X POST -H "authorization: Bearer $HANDOFF_API_KEY" $BASE/v1/grants/GRANT_ID/approve # or /deny
Limits (free, unclaimed accounts)
- 100 MB total, 50 files, 10 MB per file, 200 successful opens per day.
- Allowed file types include text, markdown, HTML, code, CSV, JSON, PDF, images, Office documents and zip archives.
- Executables are refused.
- Link previews from Slack, X, Discord and similar unfurlers don't consume opens.
- Signup is rate-limited per IP.
Download responses
| Status | Error | Meaning |
|---|---|---|
410 | expired | The link is past its expiry. |
410 | limit | The link hit its max opens. |
429 | limit | The account hit its daily cap. |
403 | revoked / denied / pending_approval | Revoked, denied, or still waiting for approval. |
401 | unauthorized | The link is for a different agent. |
Security model
- Files are private by default. Every share is an explicit grant with its own token.
- Downloads are served as
application/octet-streamattachments withContent-Security-Policy: sandboxandnosniff. - Every attempt, successful or refused, is logged.
- API keys are stored hashed and shown once.
Report security issues privately (see CONTRIBUTING.md).
License
MIT. This applies to the docs and examples in this repository. The hosted service is operated by Juel Uddin.