uploadthefile.com

The A2A binding

upload.tf is an A2A (Agent2Agent) agent. Another agent can delegate a task to it: send a message carrying a website's files and get back a live URL, or ask it to list, describe or delete the sites it has already published for you. The protocol binding is JSON-RPC 2.0 over a single HTTP POST, and it speaks A2A 1.0 and nothing else.

A2A or MCP? They are not alternatives for the same job

This site exposes both, and they are shaped for different callers. The MCP connector is a client-and-server relationship with a human in the loop: a person connects upload.tf to Claude, ChatGPT, Cursor or Zed, the assistant sees five named tools, and the person reads the answers. A2A is peer-to-peer task delegation: your agent addresses this agent directly, hands it a task, and polls the task to completion. Nobody is watching the transcript.

What that changes is the surface, not the substance. Both bindings are thin adapters over one implementation, so a publish over A2A runs byte-for-byte the same code as a publish over MCP, and the same code as a browser upload. Plan caps, file limits, link expiry, content screening, moderation holds and rate limits are decided once, underneath both, and no protocol gets a weaker version of any of them.

The endpoint

POST https://uploadthefile.com/api/a2a

The Agent Card is at /.well-known/agent-card.json, which is also this surface's entry in the RFC 9727 API catalog. Send Content-Type: application/json or application/a2a+json; responses are always application/json, which is what the JSON-RPC binding fixes. One request object per call: batches and notifications (a message with no id) are refused rather than best-effort executed, because silently running a publish nobody asked for a response to, or turning one HTTP request into N publishes, is worse than an error.

The A2A-Version header is optional. Absent is read as 1.0; a value present and not 1.0 is refused with -32009. Only major and minor are compared, so 1.0.3 is accepted.

There is no anonymous path. Every call needs a credential.

Publishing through the website needs no account, and auth.md says so. That does not extend here. A2A reaches the same publish and delete operations the connector does, on a named account's sites, so it is gated identically. An unauthenticated request gets a 401 with a WWW-Authenticate header pointing at this resource's own RFC 9728 metadata, /.well-known/oauth-protected-resource/api/a2a. Your identity comes from the verified credential and never from anything in the request body.

Getting a credential

Two credentials authenticate here, and the Agent Card declares both because both genuinely work.

An account API key

A key the account owner generated themselves and pasted to you, sent as x-api-key or as a bearer token. It carries no scopes: it satisfies every operation this agent exposes, bounded by the account plan rather than by a grant. It is gated on the plan's apiAccess entitlement, which today means Business and Corporate. Free and Pro accounts cannot mint one at all, so an API key is not the path for a caller serving arbitrary users.

You cannot mint a key on a user's behalf, and there is no endpoint that would let you.

OAuth 2.1 with PKCE, via Dynamic Client Registration

The default, and the one available on every plan including Free: connector access is gated on a separate connectorAccess entitlement that is on by default everywhere. Register your client at POST /api/oauth/register (RFC 7591, public, no client secret ever issued), then run the authorization-code flow with mandatory PKCE S256.

auth.md is the authoritative walkthrough and is not restated here, deliberately: two copies of an OAuth flow is one copy that goes stale. Read it for registration, consent, the token exchange, refresh-token rotation and revocation.

One difference matters, and it is the only one. auth.md's worked example asks for the MCP resource. Ask for this one:

resource=https://uploadthefile.com/api/a2a

RFC 8707 audiences are matched as exact strings, so an existing MCP connector token does not work here. Same authorization server, same two scopes, different resource. A token minted with no resource indicator at all remains valid anywhere.

The scopes are sites:read (list and describe) and sites:write (publish, republish, delete). A human approves them on a consent screen in a browser, once. There is no autonomous path to a credential, and probing for one wastes round trips.

The four methods

Method names are PascalCase, matching the gRPC convention the ProtoJSON binding uses. These are the exact strings the route dispatches on.

MethodParamsResultScope
SendMessage{ message, historyLength? }{ task: Task }sites:read or sites:write, depending on the resolved intent
GetTask{ id, historyLength? }Tasksites:read
CancelTask{ id }Tasksites:write
ListTasks{ contextId?, status?, pageSize?, pageToken?, historyLength?, includeArtifacts? }{ tasks, nextPageToken, pageSize, totalSize }sites:read
  • GetTask and CancelTask take the task id as params.id, not params.taskId. That is the proto field name, and it is the single easiest thing to get wrong here.
  • SendMessage wraps its task as result.task; GetTask and CancelTask return the Task as result itself. Handle both shapes.
  • historyLength is three-valued: omitted returns the whole history, 0 omits the history field entirely, and a positive integer returns at most that many of the most recent messages.
  • ListTasks pages with an opaque cursor. pageSize defaults to 50 and is capped at 100; results are ordered by status timestamp, newest first. nextPageToken is always present and is the empty string on the last page. A token this server did not issue is an invalid-params error rather than a silent restart at page one, so a paging client is never left looping. artifacts are omitted unless you pass includeArtifacts: true.
  • Task execution is synchronous. The work is finished before the response is written, so a task has already reached a terminal or interrupted state by the time you read it. Polling with GetTask is for later reads, not for waiting on a background worker. There is no background worker.

What is not implemented, and how it says so

The Agent Card declares streaming: false, pushNotifications: false and extendedAgentCard: false. All three are meant literally: there is no SSE stream, no webhook delivery for task updates, and no authenticated extended card. Do not build against any of them.

The methods those capabilities would carry are still known to this agent, and they answer knowingly. A capability error tells you the operation exists and is switched off; a bare -32601 Method not found would misreport it as something this agent has never heard of, which would send you looking for a typo.

MethodCapability declared falseAnswers with
SendStreamingMessagestreaming-32004
SubscribeToTaskstreaming-32004
CreateTaskPushNotificationConfigpushNotifications-32003
GetTaskPushNotificationConfigpushNotifications-32003
ListTaskPushNotificationConfigspushNotifications-32003
DeleteTaskPushNotificationConfigpushNotifications-32003
GetExtendedAgentCardextendedAgentCard-32004

Note GetExtendedAgentCard answers -32004 UnsupportedOperation, not -32007: the capability is declared false, not declared true and left unconfigured. The Agent Card also carries no signatures entry, because it is not signed and claiming otherwise would be the same species of untruth.

Three skills, and how a message resolves to one

The Agent Card advertises three skills over five underlying operations. The boundaries are drawn where the capability and its required authorization change, not where a function signature does.

Skill idCoversScope
publish-static-sitePublish, and both halves of the two-step republish (stage, then confirm or cancel).sites:write
manage-published-sitesList every site on the account, and describe one by name.sites:read
remove-published-siteTake a site offline and delete its files. Irreversible.sites:write

Intent resolution is deterministic. No model is involved.

A2A carries a message, not a method name, so this agent has to decide what was asked. It does that with plain rules, in a fixed order, with no language model anywhere in the path. The Agent Card says so, and the reason is that declaring general natural-language understanding while shipping keyword matching would be exactly the overclaim this binding exists to avoid. The order is:

  1. 1. A url part with no file parts is refused with -32005. This agent does not fetch content from addresses you choose: an authenticated server-side fetcher pointed at arbitrary URLs is a server-side request forgery primitive, and publishing a site needs no such thing. Refused loudly rather than ignored quietly, so you learn the bytes never arrived.
  2. 2. A structured data part naming the operation wins outright and skips verb matching entirely. Send {"operation":"get","name":"my-site"} as a part's data. The accepted values are publish, confirm, cancel, list, get and delete. If you are not an LLM, use this and stop reading the rest of this list.
  3. 3. File parts with no explicit operation mean publish. It is the only thing files can mean.
  4. 4. Otherwise the text is matched against a small verb vocabulary, most specific first: delete words (delete, remove, unpublish, take down, tear down), then publish words, then list words, then status words. Publish is checked before list on purpose: "publish my website" contains "my website", which the list vocabulary legitimately matches, and reading a publish request as a listing would be a silently wrong answer rather than a visible one.
  5. 5. Anything unresolved becomes a question, never a guess. The task goes to TASK_STATE_INPUT_REQUIRED with a prompt describing what this agent can do. Reply on the same task.

How a site name is resolved, and the guard on unquoted names

For get and delete, a name is required and is resolved in this order: a data part's name field wins; then a quoted name in the text; then, only if the text contains exactly one token that could be a site name, that token.

That last fallback carries an extra guard, and it prevents a real data-loss bug. An unquoted token only counts as a name if it contains a hyphen or a digit. So my-portfolio and site2 qualify, while old, blog and thing do not.

The reason: "delete my old site" contains four words that all satisfy the site-name character rules, none of which the caller meant as a name. Guessing between them is how an agent deletes the wrong thing. A stop-word list alone cannot carry this, because the failure mode of a fixed list is the word nobody thought of, and here that word costs someone their site. Ordinary English has neither hyphens nor digits inside a word, so the guard separates a name someone chose from a word they happened to write. If a site genuinely is one plain word, quote it or send it in a data part. The clarification prompt tells the caller exactly that, and the task waits rather than acting.

The two-step publish

Publishing to a name that is free, or sending no name at all, publishes immediately: the task reaches TASK_STATE_COMPLETED and the live URL is in the task's artifact.

Publishing over a site the account already has never overwrites it in one call. The new bytes are staged, a preview link is issued, the live site is untouched, and the task lands in TASK_STATE_INPUT_REQUIRED with a status message whose data part carries status: "staged", the preview URL and its expiry. This is A2A's own interrupted-task mechanism doing exactly the job it exists for.

To finish, send a second SendMessage carrying the same taskId and either {"operation":"confirm"} or {"operation":"cancel"} (the words "confirm" and "cancel" in text work too). Confirming commits and completes the task; cancelling moves it to TASK_STATE_CANCELED and leaves the live site exactly as it was.

You never handle the confirm token. It is the capability that commits the change, so it stays on the server, stripped out of the staged payload you see and cleared the moment the task leaves the interrupted state. Cancelling a task, or letting the 15-minute publish session simply expire, both leave the live site unchanged. While a task is waiting on a confirmation, the only follow-ups it accepts are confirm, cancel, or a repeat of the question.

Error codes

JSON-RPC's own codes in the -32700 to -32603 band, then A2A's assigned range -32001 to -32009, plus -32000 for insufficient scope, which sits in JSON-RPC's implementation-defined band and collides with no code A2A assigns. An A2A error's data is an array carrying a google.rpc.ErrorInfo object with an UPPER_SNAKE_CASE reason, a domain of a2a-protocol.org, and a string-valued metadata map.

A well-formed request that produced a JSON-RPC error still answers HTTP 200: the error is the result. The exceptions are listed below.

CodeNameHTTPWhen
-32700Parse error400The body is not valid JSON.
-32600Invalid request400Not a single JSON-RPC request object. A batch and a notification (a message with no id) are both refused here rather than best-effort executed.
-32601Method not found200A method name this agent has never heard of. A method it knows but does not run answers with a capability error instead.
-32602Invalid parameters200Params failed validation. Carries a google.rpc.BadRequest detail in error.data with one fieldViolations entry per problem, so you learn every fault at once.
-32603Internal error500An operation threw. That is a bug on this side, logged server-side and never echoed to you.
-32000Insufficient scope403The request is well formed but your grant does not cover it. error.data names the required scope in metadata.requiredScope. No task is created: a task record is the story of work you were allowed to ask for.
-32001TaskNotFound200No task with that id belongs to you. Another account's task is reported identically to one that does not exist.
-32002TaskNotCancelable200CancelTask on a task already in a terminal state (completed, failed, canceled or rejected).
-32003PushNotificationNotSupported200One of the four push-notification-config methods. The Agent Card declares pushNotifications false and means it.
-32004UnsupportedOperation200A streaming or extended-card method, or a message sent to a task that has already finished. metadata names the method or the task and its state.
-32005ContentTypeNotSupported200A part this agent structurally cannot accept: a url part with no accompanying file parts, or file parts that carried no content at all.
-32006InvalidAgentResponsenever sent200Reserved by the specification. This binding never returns it.
-32007ExtendedAgentCardNotConfigurednever sent200Reserved by the specification. This binding never returns it: GetExtendedAgentCard answers UnsupportedOperation, because the capability is declared false rather than declared true and left unconfigured.
-32008ExtensionSupportRequirednever sent200Reserved by the specification. This binding declares no extensions, so it never returns it.
-32009VersionNotSupported200An A2A-Version header naming a version other than 1.0. An absent header is accepted and read as 1.0; only a present, wrong value is refused.

Before any of that, the transport can answer 401 (no credential, or an invalid, expired or revoked one), 403 (the plan does not carry the entitlement), 415 (unacceptable Content-Type), 413 (a declared body over 75 MB, refused before it is buffered) or 429 with Retry-After. Those are plain error envelopes, not JSON-RPC objects.

A refusal from the operation itself, meaning a plan cap, a moderation hold, a name already taken by someone else, or a site that is not yours, is not a protocol error. The task completes its lifecycle into TASK_STATE_FAILED and the status message carries the core's own explanation verbatim. Read it and relay it.

End to end

List what exists, publish a one-page site, then read the task back. Set $TOKEN to an access token minted for resource=https://uploadthefile.com/api/a2a (or use -H "x-api-key: utf_…" instead).

1. List your tasks

curl -sS https://uploadthefile.com/api/a2a \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "A2A-Version: 1.0" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "ListTasks",
    "params": { "pageSize": 10, "historyLength": 0 }
  }'
Example response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tasks": [],
    "nextPageToken": "",
    "pageSize": 10,
    "totalSize": 0
  }
}

2. Publish a site

Each file is one part. A part carries its bytes in raw, which is base64 (it is the proto's bytes field, and ProtoJSON encodes bytes as base64), or its text in text. A part counts as a file part when it has raw or a filename. A single file with no filename is treated as index.html. The name is optional: omit it and one is chosen for you.

curl -sS https://uploadthefile.com/api/a2a \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "SendMessage",
    "params": {
      "message": {
        "messageId": "11111111-1111-4111-8111-111111111111",
        "role": "ROLE_USER",
        "parts": [
          { "data": { "operation": "publish", "name": "my-portfolio" } },
          {
            "filename": "index.html",
            "mediaType": "text/html",
            "text": "<!doctype html><title>Hi</title><h1>Hello</h1>"
          },
          {
            "filename": "logo.png",
            "mediaType": "image/png",
            "raw": "iVBORw0KGgoAAAANSUhEUg…"
          }
        ]
      }
    }
  }'
Example response
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "task": {
      "id": "9f1c…",
      "contextId": "3ab8…",
      "status": {
        "state": "TASK_STATE_COMPLETED",
        "timestamp": "2026-08-17T09:12:44.128Z"
      },
      "artifacts": [
        {
          "artifactId": "c07d…",
          "name": "result",
          "parts": [
            { "text": "Published! Your website is live at https://my-portfolio.upload.tf" },
            {
              "data": {
                "status": "live",
                "url": "https://my-portfolio.upload.tf",
                "name": "my-portfolio",
                "expiresAt": "2026-09-16T09:12:44.000Z",
                "retentionDescription": "expires in 30 days",
                "plan": "free",
                "fileCount": 2,
                "totalBytes": 5142,
                "limits": {
                  "maxFiles": 300,
                  "maxFileBytes": 20971520,
                  "maxTotalBytes": 52428800,
                  "remainingBytes": 1068498944
                }
              }
            }
          ]
        }
      ],
      "history": [ … ]
    }
  }
}

Had my-portfolio already existed on this account, the same call would have returned TASK_STATE_INPUT_REQUIRED with a staged payload and a preview link instead. Keep the task.id either way.

File parts are not kept in the task history. What survives is what identifies a file (its name, media type and byte length); the bytes are dropped, and a text part over 2000 characters is truncated. Storing an entire uploaded site in a task row would buy nothing.

3. Read the task back

The id goes in params.id, and the result is the Task itself with no task wrapper.

curl -sS https://uploadthefile.com/api/a2a \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "GetTask",
    "params": { "id": "9f1c…", "historyLength": 2 }
  }'
Example response
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "id": "9f1c…",
    "contextId": "3ab8…",
    "status": {
      "state": "TASK_STATE_COMPLETED",
      "timestamp": "2026-08-17T09:12:44.128Z"
    },
    "artifacts": [ … ],
    "history": [ … ]
  }
}

Limits, expiry and rate limits

Identical to a browser upload, because the same code decides them. A2A is an adapter, not a second implementation, and there is no branch anywhere in it that asks which protocol a request arrived on.

  • File limits. A first publish is judged against fixed Worker-memory bounds that no plan raises. Republishing an existing site is judged against the account's plan-derived per-file ceiling. Every result's limits object reports the real numbers that were applied, alongside remainingBytes of account storage. A request over budget fails with which limit and by how much, never quietly trimmed.
  • Expiry. Plan-derived, and always reported from the stored row rather than recomputed: expiresAt plus a plain-language retentionDescription derived from that same value, so the two can never disagree. A preview link from a staged republish has its own separate 15-minute TTL in previewExpiresAt.
  • Content screening and moderation. Same pipeline, same holds. A site withheld on legal grounds cannot be deleted through this binding and says so.
  • Rate limits. Plan-scaled, and A2A shares one budget with the MCP connector under the same subject key. Speaking both protocols does not buy a caller twice the ceiling of one that speaks either. Over the limit is a 429 with Retry-After.
  • Isolation. Tasks and sites are scoped to the account the credential belongs to. A task or a site that is not yours is reported exactly as one that does not exist, so neither can be used to discover whether a name is taken by someone else.

Also useful: the authentication reference, the MCP connector guide if a human is in the loop, or the REST API reference.