Kodwen docs
Gateway

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"
}
FieldMeaning
asset_idStable UUID. Use it to get a fresh URL or delete the asset.
kindmusic, video, image, speech, render, upload or sfx.
statuspending, ready, failed or deleted. Only ready assets have a url.
modelUpstream model that produced it.
urlSigned URL of the bytes, valid for 1 hour; null unless ready. Never cache it past expires_at.
mime_type, bytesContent type and size of the stored object.
duration_secondsAudio or video length; null for images.
width, heightPixels, when known; otherwise null.
created_atCreation time. The asset is removed 90 days later.
expires_atWhen url stops working; null when there is no URL.

Endpoints

MethodPathResult
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}/content302 redirect to the signed URL
DELETE/v1/assets/{id}{ id, object: "asset", deleted: true }
POST/v1/assets/uploads201 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}/ingest202 { 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:

  1. POST /v1/assets/uploads with { filename, mime, bytes, ingest?, origin? } returns an upload_url. It is a bearer secret for that one object — do not log it. Pass origin when uploading from a browser.
  2. PUT the bytes to upload_url in chunks that are multiples of 262144 bytes (8 MiB works well), each with Content-Range: bytes <first>-<last>/<total> and no Authorization header. 308 means continue: its Range: bytes=0-<n> header is how much storage has. The last chunk answers 200/201. After a network error, PUT an empty body with Content-Range: bytes */<total> and continue from the returned Range.
  3. POST /v1/assets/uploads/{id}/complete checks the size, marks the asset ready (kind: "upload") and starts ingest when ingest was true (the default). 409 upload_incomplete means 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 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/generations and POST /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

HTTPcodeWhen
400bad_requestInvalid kind, before (must be an ISO date-time) or limit.
404not_foundUnknown id, another user's asset, or /content on an asset without content.
400invalid_originorigin on an upload is not an allowed browser origin.
402insufficient_fundsThe wallet cannot cover the ingest quote.
409upload_incomplete / asset_not_readycomplete before all bytes arrived; ingest of an asset that is not ready.
413file_too_large / storage_quota_exceededOver 4 GiB per file or 50 GiB per user.
415unsupported_media_typeA type outside the list above, or ingest of an image.
503ingest_unavailable / search_unavailableIngest or search is not configured on this gateway.
503assets_unavailableAsset 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)

On this page