Why the handle exists
Tool arguments pass through a model’s context window exactly like tool results do. Sending a photograph asimage_base64 therefore means the agent emits the
whole file as tool-call arguments, and a 3 MB image is 4 MB of base64, on the
order of a million tokens. What happens next is not theoretical: the payload is
truncated, the run fails with something that reads like file corruption, and the
agent downscales the user’s photo and tries again with a smaller one.
A handle is about thirty characters. The bytes go from you straight to storage,
so they never pass through the model and never pass through Goro’s API either.
Two roads, and how to pick
The response carries two ways to get the bytes in. They write the same object to the same path and produce the same handle, so nothing downstream can tell them apart. Which one is right depends on one question: can you actually reach the file?
If you are an agent without a shell, send the user
upload_page_url and wait
for them to say it is done. Do not fall back to image_base64: a large inline
payload is the failure this whole mechanism exists to prevent, and some clients
refuse to render a tool call that size without reporting an error at all.
The three steps
1. Ask for somewhere to put the file
The type you declare decides the object’s extension and the type the store will
serve it as. It is a claim rather than evidence, and it is treated as one: the
file’s own leading bytes are read when the handle is redeemed, and a pair that
disagrees is refused.
Values are illustrative. Treat
upload_url as opaque: it addresses the storage
provider rather than api.usegoro.ai, and it carries its own credential in the
query string.
Both upload_page_url and upload_url are capability URLs. Anyone holding one
can write that one file to that one path until it expires, so treat them the way
you would treat any signed link: hand them to the person they were minted for
and nobody else.
2. Either PUT the file, or send the user the page
If you have a shell and the file is on this machine:Authorization header with it. The URL is the credential, it is good
for one write to one path, and it points at the private bucket directly. That is
the whole point of the two requests: the file never enters a Goro serverless
function, never meets a request body limit, and never enters the model.
Otherwise, give the user upload_page_url:
Open this link and choose the photo: https://upload.usegoro.ai/…
Tell me when it’s uploaded.
They get a one-field page that checks the file is really the type you asked for
and sends it from their browser straight to the same bucket. Nothing is
installed, nothing is signed into, and the bytes never travel through you. Wait
for them to confirm before step 3: the handle does not resolve until the file
has actually landed.
The page checks the file’s leading bytes before sending, so a user who picks
an iPhone HEIC when you asked for
image/jpeg is told immediately, on the
page, instead of failing minutes later inside a paid run.3. Run the tool with the handle
upl_, then 22 base64url characters, then .jpg, .png or
.webp. Nothing else parses, because the handle becomes part of a storage path.
It carries the object id and the extension and nothing else: the workspace comes
from the key you authenticated with, so another workspace’s handle looks in your
own prefix for an object that is not there.
Three ways to send a starting image
Exactly one of these per call. Sending two is refused, because picking a winner would mean animating an image you did not mean and charging you for it.image_base64 is not going away, but “I have no shell” stopped being a reason
to use it: that is what upload_page_url is for. It remains the right answer
for a small file when there is no user to ask either. It is the wrong answer for
a photograph, and the failure it produces is silent enough to be worth naming
twice: a truncated base64 string decodes to fewer, wrong bytes rather than to an
error, and some clients will not even render a tool call that large, which looks
like the request vanishing.
Why the ceilings differ
The handle path and the link path both stop at 10 MB, which is kie.ai’s own per-image limit, so Goro rejects exactly what the model would reject and nothing more. On the upload path that limit is also declared on the bucket, so an oversized file is refused by the storage layer before a byte of it is stored. The inline path stops at 3 MB for a reason that has nothing to do with images. Base64 inflates by exactly 4/3, so 3 MB of image is 4 MB of request body, and the serverless request body limit behind this API is 4.5 MB. That ceiling is a property of the transport and cannot be raised. An upload does not go through that transport at all, which is the second reason it exists.The two clocks
The page clock extends nothing. Retention is measured from the moment the bytes
land, so a link opened at minute 59 produces a file deleted 30 minutes after
that, exactly like one opened immediately. What the hour buys is the gap between
an agent posting a link and a person picking up their phone.
The second number did not change when uploads were added. The ten minutes are
subtracted from the thirty rather than added to them: the signed link handed to
the generative provider is worth the twenty that remain, so a link can never
outlive the object it points at, and no image lives past thirty minutes. The file is deleted by a
scheduled sweep that reads the bucket by age every ten minutes, which is the same
job, the same bucket and the same clock the inline intake already used.
The practical rule is one line: get the handle immediately before the run, not in
advance. A handle from an earlier session is expected to be gone.
What is stored, and what is not
Nothing about the upload path is more exposed than the inline one, and in one respect it is less: the bytes reach storage without passing through our API.- The bucket is private. There is no public read and no unsigned address that works.
- The object’s name is random and sits under a path scoped to your workspace. Nothing about it is derivable from the run record, deliberately, which is why the deletion sweep reads the bucket rather than working backwards from a run.
- The only link that exists is signed and time limited, and it is handed to the generative provider, not published.
- An unredeemed handle leaves nothing behind. The object is swept at thirty minutes like every other one.
Errors
Minting a handle can fail in two ways.
Everything else surfaces on the run that redeems the handle, and all of it is
checked before the hold is placed, so none of it charges you.
A handle that does not resolve is settled before the run is quoted, so a bad
handle costs nothing: no hold, no run row, nothing to release.
From MCP
The MCP server exposes the same endpoint ascreate_upload. It takes
content_type and returns the same handle, the same page link and the same
upload URL, with the exact curl to run next. It needs the tools:read scope
rather than tools:run, because it spends nothing.
The instruction for an agent is short enough to repeat: never read a file into
the conversation in order to send it. Call create_upload. If you have a shell
and the file is on that machine, PUT it. Otherwise give the user
upload_page_url and wait for them. Either way, pass the handle to run_tool.
Do not resize the image to make it fit, because fitting is not the problem the
size was causing.
From the CLI
goro run takes --image and does all three steps for you.
image_handle, and a .jpg that is really a PNG still works. See
goro run.
Spend it on a run
What a run costs, what it returns, and how the hold settles.