Storage that survives AI-generated code: no IAM, no SDK, one token
Why coding agents botch S3 setup, what a REST-only storage API fixes, and the two Infrai behaviours that will still fool a model unless you tell it about them.
The reason a coding agent keeps getting S3 wrong isn’t that it’s bad at S3. It’s that “add file storage” on AWS means authoring four coupled artefacts — an IAM policy, a bucket policy, a CORS configuration and an SDK credential chain — none of which fail at generation time, and all of which fail at 5pm on a Friday with AccessDenied. Infrai’s storage collapses that to one bearer token and plain HTTPS calls, which is a much smaller target to miss.
That’s the honest core of the answer, and it’s a claim about surface area rather than about intelligence. A model writing fetch() against a documented URL has one thing to get right. A model writing @aws-sdk/client-s3 v3 has to pick the right client, the right command object, the right credential provider, and a policy document whose Resource ARN needs a /* suffix that nothing in the code will remind it about. Infrai storage has no SDK to version-skew and no policy language at all — access is the key or nothing.
The whole integration, start to finish
export INFRAI_API_KEY="your_infrai_api_key"
# 1. make a bucket
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"agent-demo","acl":"private"}'
# 2. confirm it's there
curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
No role to assume, no profile to configure, no region in a config file that disagrees with the region in the client constructor. region is optional and validated — ask for one the vendor isn’t provisioned in and create refuses with a 400 that names the region it does serve, which is the kind of error an agent can actually act on. One environment variable is the entire credential story, which also means the agent can’t invent a second one.
Let the model look the API up instead of recalling it
This is the part that changes generated-code quality most, and it’s underused. GET /v1/discovery returns every live route with its method, path, availability and billing — a machine-readable index the agent can fetch during the task rather than reconstructing from training data that may be a year stale.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['method'], c['path'], 'free' if c['billing']['free'] else c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Put that command in your CLAUDE.md or your agent’s system prompt with one instruction — check the route exists here before you write it — and hallucinated endpoints mostly stop. It’s the same trick as giving a compiler to a code generator: a cheap oracle beats a confident guess.
Errors are typed too, which matters because an agent’s retry loop reads them.
{
"ok": false,
"error": {
"code": "STORAGE_BUCKET_NOT_FOUND",
"http_status": 404,
"message": "bucket not found",
"docs_url": "https://docs.infrai.cc/errors",
"retryable": false,
"trace_id": "trc_091ecad3873f41788c8b5c5d"
}
}
retryable: false is a directive, not a hint. A generated retry loop that respects it won’t hammer a 404 sixteen times with exponential backoff.
A storage module small enough to review
Here’s the whole thing an agent should generate — roughly 40 lines, no dependencies, and short enough that you’ll actually read the diff.
// storage.mjs — Node 22 ESM, no SDK
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const jsonHeaders = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function call(path, { method = "GET", body } = {}) {
const res = await fetch(`${API}${path}`, {
method,
headers: method === "GET" ? { Authorization: `Bearer ${KEY}` } : jsonHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const err = payload.error ?? { code: "HTTP_" + res.status, message: res.statusText };
const wrapped = new Error(`${err.code}: ${err.message}`);
wrapped.retryable = Boolean(err.retryable);
throw wrapped;
}
return payload.data;
}
export const putObject = (bucket, key, bytes, contentType) =>
call(`/v1/storage/object/put/${bucket}/${key}`, {
method: "PUT",
body: { data_base64: Buffer.from(bytes).toString("base64"), content_type: contentType },
});
export const headObject = (bucket, key) => call(`/v1/storage/object/head/${bucket}/${key}`);
export const listObjects = (bucket, prefix) =>
call(`/v1/storage/object/list/${bucket}?prefix=${encodeURIComponent(prefix)}&limit=200`);
export const presignDownload = (bucket, key, seconds) =>
call(`/v1/storage/object/presign/${bucket}/${key}`, {
method: "POST",
body: { op: "get", expires_seconds: seconds },
});
Base64 in a JSON body is fine below 1 MB and explicitly not recommended above it — for bigger files the agent should reach for presigned uploads or the multipart routes instead.
Two behaviours that will still fool a model
Neither of these is something a language model can infer from the shape of the API, so put both in your project instructions. The catch is that both failures look like working code right up until they don’t.
A signed URL is a bearer token, not a per-user permission. Private objects do refuse an unsigned request — strip the query string and the storage host returns 403, which we re-checked on 27 July 2026 — but anyone holding the signed link can fetch until it expires, and a model that pastes one into a client bundle or an email has handed out the object. So the authorisation decision belongs in the route that mints the URL, before it exists. Keep TTLs short, and derive keys server-side from something unguessable rather than from a filename or a sequential id.
// keys.mjs — unguessable, server-derived object keys
import { createHash, randomBytes } from "node:crypto";
export function objectKeyFor(userId, filename) {
const salt = process.env.STORAGE_KEY_SALT;
if (!salt) throw new Error("STORAGE_KEY_SALT is not set");
const nonce = randomBytes(16).toString("hex");
const digest = createHash("sha256").update(`${salt}:${userId}:${nonce}`).digest("hex");
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
return `u/${userId}/${digest.slice(0, 32)}${ext}`;
}
The browser cannot reach the bucket. There is a route that stores CORS rules — POST /v1/storage/bucket/set_cors/{bucket}, and bucket/get reads them back — but a preflight from a web page to the storage origin returns 403 with no Access-Control-Allow-* header, so the browser stops there. Any agent-generated frontend that uploads straight from the browser to a signed URL fails with an opaque network error and then gets “fixed” three times in a row by a model that can’t see the preflight. Tell it up front: uploads go through your own API route, which is a fetch to object/put and about ten lines — or that one workload goes to R2 or S3, where the rules reach the object host.
Where this genuinely isn’t the answer
| Situation | Better pick | Why |
|---|---|---|
| The app already runs on EC2 with instance roles | S3 | The credential problem is already solved; adding a second vendor adds a secret to rotate |
| Browser-direct uploads with no proxy hop | Cloudflare R2, S3 | Their CORS configuration is applied by the object host |
| Data must stay on hardware you control | MinIO | S3-compatible, self-hosted, same mental model |
| You want folders, rows and RLS handed to you | Supabase | Storage plus a database row per object |
| A generated app that needs storage, queue, cron, email and error capture | Infrai | One key covers all of them; no five-vendor onboarding |
That last row is the real argument, and it’s not about the storage rate. An agent asked to add “file uploads plus a background thumbnail job plus a failure email” against five vendors writes five auth patterns and five sets of setup instructions for you to follow. Against one key it writes one: queue.publish for the thumbnail job, email.send for the notification and errors.capture for the failure are the same bearer token as object/put, with no second account to open and no second SDK for the model to get wrong.
Cost, so the generated app doesn’t surprise you
Verified 27 July 2026: bucket create, list, head, presign, lifecycle and delete are free (rate-limited). PUT /v1/storage/object/put/{bucket}/{key} costs $0.0001 per call. Reads bill by volume rather than by call — GET /v1/storage/object/get/{bucket}/{key} meters $0.104 per GB — which is worth telling the agent explicitly, because a model that has memorised a per-call read rate will size the wrong thing. New accounts get $2 of trial credit. Rates move, so read the live numbers rather than trusting a figure anyone memorised:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(d['total_cost'], d['total_calls'])"
Give the agent that command too. An app that can report its own spend is one an agent can be trusted to iterate on — a download loop is cheap per call and not cheap per gigabyte, and this is the query that catches it on day one instead of at month end.