Assets
Stored Kodwen Studio media — list, fetch a fresh signed URL, download or delete the images, music and videos you generated.
An asset is a generated file the gateway keeps for you: every image, music clip or video produced with response_format: "url", and every finished video job (the MP4 is always stored). Assets belong to the user that owns the API key — another user's asset id is a 404 — and are kept for 90 days.
Instead of base64 bytes you get an asset object with a signed URL valid for 1 hour. The URL needs no API key, so you can hand it to a browser, an <audio>/<video> element or a download tool. When it expires, ask for a fresh one.
Agents browse the library through the MCP server tools studio_assets_list (kind, before, limit) and studio_renders_list (before, limit, root_render_id). Both are read-only and never return signed URLs; generating media costs money and is not available through them.
The asset object
{
"asset_id": "7d0f2c1e-4b8a-4c1e-9a51-0f3c2e8d9b10",
"kind": "video",
"status": "ready",
"model": "veo-3.1-fast-generate-001",
"url": "https://storage.googleapis.com/…&X-Goog-Signature=…",
"mime_type": "video/mp4",
"bytes": 1834221,
"duration_seconds": 4,
"width": null,
"height": null,
"created_at": "2026-10-10T08:00:00.000Z",
"expires_at": "2026-10-10T09:00:00.000Z"
}| Field | Meaning |
|---|---|
asset_id | Stable UUID. Use it to get a fresh URL or delete the asset. |
kind | music, video, image, speech, render, upload or sfx. |
status | pending, ready, failed or deleted. Only ready assets have a url. |
model | Upstream model that produced it. |
url | Signed URL of the bytes, valid for 1 hour; null unless ready. Never cache it past expires_at. |
mime_type, bytes | Content type and size of the stored object. |
duration_seconds | Audio or video length; null for images. |
width, height | Pixels, when known; otherwise null. |
created_at | Creation time. The asset is removed 90 days later. |
expires_at | When url stops working; null when there is no URL. |
Endpoints
| Method | Path | Result |
|---|---|---|
GET | /v1/assets?kind=&before=&limit= | { object: "list", data: [asset…], next_before }, newest first |
GET | /v1/assets/{id} | One asset with a fresh signed url |
GET | /v1/assets/{id}/content | 302 redirect to the signed URL |
DELETE | /v1/assets/{id} | { id, object: "asset", deleted: true } |
POST | /v1/assets/uploads | 201 upload session { id, upload_url, chunk_granularity, max_bytes, expires_at } |
POST | /v1/assets/uploads/{id}/complete | { asset, ingest: { status, quote_micro_usd? } } |
GET | /v1/assets/quota | { used_bytes, limit_bytes, max_file_bytes } |
GET | /v1/assets/{id}/ingest | { status, error?, summary?, result?, sprite_url? } |
POST | /v1/assets/{id}/ingest | 202 { status, quote_micro_usd? } |
GET | /v1/assets/search?q=&limit= | { object: "list", data: [{ asset_id, t_ms, kind, score, text? }] } |
Listing
kind filters by kind, limit is 1–100 (default 30). To page, pass the previous response's next_before as before until next_before is null.
curl "https://ai.codai.ro/v1/assets?kind=video&limit=20" \
-H "Authorization: Bearer $CODAI_API_KEY"Fetching the bytes
GET /v1/assets/{id} returns the asset with a newly signed URL. GET /v1/assets/{id}/content does the same and redirects to it, which is handy with curl -L. The redirect carries cache-control: private, no-store. An asset with no content yet (pending) is a 404 on /content.
curl -L -o plane.mp4 "https://ai.codai.ro/v1/assets/$ASSET_ID/content" \
-H "Authorization: Bearer $CODAI_API_KEY"Deleting
DELETE /v1/assets/{id} removes the stored file and marks the asset deleted; URLs already handed out stop working. Deleting does not refund the generation.
Uploading your own media
Upload video, audio and images up to 4 GiB per file and 50 GiB per user (GET /v1/assets/quota). The bytes go straight to Google Cloud Storage through a resumable session, never through the gateway:
POST /v1/assets/uploadswith{ filename, mime, bytes, ingest?, origin? }returns anupload_url. It is a bearer secret for that one object — do not log it. Passoriginwhen uploading from a browser.PUTthe bytes toupload_urlin chunks that are multiples of 262144 bytes (8 MiB works well), each withContent-Range: bytes <first>-<last>/<total>and noAuthorizationheader.308means continue: itsRange: bytes=0-<n>header is how much storage has. The last chunk answers200/201. After a network error,PUTan empty body withContent-Range: bytes */<total>and continue from the returnedRange.POST /v1/assets/uploads/{id}/completechecks the size, marks the assetready(kind: "upload") and starts ingest wheningestwastrue(the default).409 upload_incompletemeans the bytes are not all there yet.
Accepted types: video/mp4, video/quicktime, video/webm, video/x-matroska, audio/mpeg, audio/wav, audio/x-wav, audio/mp4, audio/aac, audio/flac, audio/ogg, audio/webm, image/png, image/jpeg, image/webp, image/gif, image/heic.
Ingest and search
Ingest analyses an uploaded video or audio file once, so Studio and agents can edit it by meaning: transcript with word timings and speakers, shots with a sprite sheet of keyframes, beats, face and subject tracks, loudness/blur signals and search embeddings. It is priced per minute of media and money-gated: the price is quoted and held on your wallet before the job starts and settled when it ends. With too little balance, complete reports ingest.status: "insufficient_funds" (the asset stays ready) and POST /v1/assets/{id}/ingest answers 402 insufficient_funds.
GET /v1/assets/{id}/ingest returns status (none, queued, running, succeeded, failed, insufficient_funds, unsupported); once succeeded it also has summary, the full result (ingest.json, version 1) and sprite_url (1 h). GET /v1/assets/search?q=red%20car finds moments (t_ms) across all your ingested media, matching keyframes and transcript segments.
Creating assets
Ask for response_format: "url" on any media endpoint (see Embeddings, audio & realtime):
POST /v1/images/generationsandPOST /v1/images/edits— body field;POST /v1/audio/music— body field;GET /v1/videos/{id}— query parameter?response_format=url.
The default stays b64_json, so existing clients see no change. Videos are billed exactly once and their MP4 is stored whichever format you poll with.
Every finished Studio render is stored as an asset too and appears under kind=render (GET /v1/assets?kind=render).
Sound effects imported from the free CC0 sound-effects library are assets of kind=sfx.
Errors
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | Invalid kind, before (must be an ISO date-time) or limit. |
| 404 | not_found | Unknown id, another user's asset, or /content on an asset without content. |
| 400 | invalid_origin | origin on an upload is not an allowed browser origin. |
| 402 | insufficient_funds | The wallet cannot cover the ingest quote. |
| 409 | upload_incomplete / asset_not_ready | complete before all bytes arrived; ingest of an asset that is not ready. |
| 413 | file_too_large / storage_quota_exceeded | Over 4 GiB per file or 50 GiB per user. |
| 415 | unsupported_media_type | A type outside the list above, or ingest of an image. |
| 503 | ingest_unavailable / search_unavailable | Ingest or search is not configured on this gateway. |
| 503 | assets_unavailable | Asset storage is not configured on this gateway. |
SDKs
import { Kodwen } from 'codai-sdk';
const client = new Kodwen({ apiKey: process.env.CODAI_API_KEY! });
const { assets } = await client.music.generate({ prompt: 'calm lo-fi piano', response_format: 'url' });
const url = await client.assets.contentUrl(assets[0]!.asset_id); // fresh 1 h URL
for await (const a of client.assets.iterate({ kind: 'video' })) console.log(a.asset_id, a.bytes);
await client.assets.delete(assets[0]!.asset_id);from Kodwen import Kodwen
client = Kodwen(api_key=os.environ["CODAI_API_KEY"])
r = client.music.generate({"prompt": "calm lo-fi piano", "response_format": "url"})
url = client.assets.content_url(r.assets[0]["asset_id"])
for a in client.assets.iterate(kind="video"):
print(a["asset_id"], a["bytes"])
client.assets.delete(r.assets[0]["asset_id"])Both SDKs also have list(...), retrieve(id), content(id) (downloads the bytes from the signed URL) and contentPath(id) / content_path(id) (the authenticated redirect URL).
Uploading a file, then reading its analysis and searching it:
const file = new Blob([await readFile('interview.mp4')]);
const { asset, ingest } = await client.assets.upload(file, {
filename: 'interview.mp4',
mime: 'video/mp4',
onProgress: (sent, total) => console.log(`${Math.round((100 * sent) / total)}%`),
});
const state = await client.assets.ingest(asset.asset_id); // poll until status is 'succeeded'
const hits = await client.assets.search('red car', { limit: 5 });done = client.assets.upload("interview.mp4", on_progress=lambda sent, total: print(sent, total))
state = client.assets.ingest(done["asset"]["asset_id"])
hits = client.assets.search("red car", limit=5)