/// FILE NO. KS—DOCS

Install. Quickstart. Reference.

Get the CLI on your machine, the SDK in your project, and the API in your head, in that order.

/// INSTALL

Two paths in.

The CLI for daily uploads, scripting, and ingest. The SDK when you're writing code against the API.

FRAME 01 · CLI

Command-line client.

curl -fsSL https://khaosstorage.com/install | sh

Drops khaos-storage and the khs alias on your PATH. Run khs update any time to upgrade in place. Requires Node 20+.

FRAME 02 · SDK

TypeScript client.

npm i https://khaosstorage.com/sdk/latest.tgz

Same package we'll publish to npm later. Works in Node and any modern bundler.

/// QUICKSTART

Three steps to first asset.

Sign in, upload, share. The CLI version is one terminal pane; the Node version is six lines of code.

01 Sign in

Authorize once, in the browser.

khs login
02 Upload

Pick files interactively.

khs upload
03 Share

Publish into a space.

khs upload showreel.mov \
  --space "Highlights"
/// API REFERENCE · v0.1

The shape of the API.

Every endpoint takes Authorization: Bearer <key>, returns { data, meta?, error? }, and is gated by a scope on the calling key. Errors carry a code, message, and (when applicable) a fields array.

Base URL

https://api.khaosstorage.com

Scopes

read · write · admin. Higher scopes inherit lower.

Errors

HTTP status, plus error.code like insufficient_scope, validation_error, not_found.

/// ASSETS

Assets

Upload, list, get, update, delete, and resolve signed download URLs. The upload helpers in the SDK and CLI choreograph this resource end to end.

POST /v1/assets write

Create an asset and receive an upload descriptor (single PUT for ≤100 MiB, multipart above). The SDK's client.upload(...) wraps this plus the part PUT and complete calls.

khs upload clip.mov
GET /v1/assets read

List assets. Supports limit, cursor, status, type, search, and modified_after.

Node

const assets = await client.assets.list({
  type: 'video',
  status: 'active',
  limit: 50,
});
GET /v1/assets/{asset_id} read

Fetch one asset by id. The response carries any populated thumb_url and preview_url as 1-hour signed GETs.

await client.assets.get('ast_…');
PATCH /v1/assets/{asset_id} write

Update tags, filename, or attributes. Returns the full asset.

await client.assets.patch('ast_…', { tags: ['raw','drone'] });
DELETE /v1/assets/{asset_id} write

Hard-delete the asset and its R2 objects (original, thumb, preview). Idempotent.

await client.assets.delete('ast_…');
GET /v1/assets/{asset_id}/url read

Mint a signed download URL for the original. Default 1-hour TTL via expires_in.

await client.assets.url('ast_…', 3600);
POST /v1/assets/{asset_id}/parts write

Mint a per-attempt presigned PUT URL for one part of a multipart upload. Refresh per attempt; URLs expire in 15 minutes.

await client.assets.presignPart('ast_…', uploadId, partNumber);
POST /v1/assets/{asset_id}/complete write

Finalize the upload. Pass upload_id + parts for multipart; empty body for single PUT. Triggers the processor fan-out.

await client.assets.completeUpload('ast_…', uploadId, parts);
POST /v1/assets/{asset_id}/abort write

Abort an in-flight multipart upload. Cleans up R2 state. Called automatically by the SDK when an upload is cancelled.

await client.assets.abortUpload('ast_…', uploadId);
POST /v1/assets/{asset_id}/reprocess write

Re-run the image / video processor. Useful after a processor bug fix or to regenerate thumbnails.

await client.assets.reprocess('ast_…');
POST /v1/assets/bulk-reprocess write

Paginated bulk reprocess. Each call processes up to limit active assets (default 50, max 100) and returns a next_cursor to continue. Loop on has_more until exhausted. Skips non-active assets server-side. Useful after a XIFty / processor upgrade lands and existing assets need fresh metadata. Same idempotent fan-out as the per-asset endpoint.

// Walk every batch:
let cursor;
for (;;) {
  const batch = await client.assets.bulkReprocess({ cursor });
  if (!batch.has_more) break;
  cursor = batch.next_cursor;
}

// Or from the CLI:
khs reprocess --all
khs reprocess --all --type image --limit 100
GET /v1/assets/{asset_id}/spaces read

List the spaces an asset is currently published into.

await client.assets.spaces('ast_…');
/// SPACES

Spaces

A space groups assets. private (default) is owner-only — useful for organizing without sharing. public resolves for anyone with the share URL. protected resolves for anyone with the URL plus a password. Assets are always private; publishing into a public/protected space is what surfaces them externally.

POST /v1/spaces admin

Create a space. visibility defaults to private (owner-only grouping). Pass public for an open share URL, or protected with a password for a password-gated URL.

await client.spaces.create({
  name: 'Highlights',
  visibility: 'public',
});
GET /v1/spaces read

List your spaces.

await client.spaces.list();
GET /v1/spaces/{space_id} read

Get one space, including asset_count.

await client.spaces.get('spc_…');
PATCH /v1/spaces/{space_id} admin

Update name, description, visibility, or password. Pass password: null to clear.

await client.spaces.update('spc_…', { visibility: 'public', password: null });
DELETE /v1/spaces/{space_id} admin

Delete the space. Cascades publications. Underlying assets are not affected.

await client.spaces.delete('spc_…');
POST /v1/spaces/{space_id}/publications write

Publish one or more assets into the space. Returns per-asset success / error.

await client.spaces.publish('spc_…', ['ast_…','ast_…']);
GET /v1/spaces/{space_id}/publications read

List the assets published into a space, decorated with signed thumb/preview URLs.

await client.spaces.listPublications('spc_…');
DELETE /v1/spaces/{space_id}/publications/{asset_id} write

Unpublish one asset from a space. The asset itself stays.

await client.spaces.unpublish('spc_…','ast_…');
/// API KEYS

API keys

Long-lived bearer tokens for scripts, ingest agents, and integrations. Each key carries an array of scopes (read / write / admin); higher scopes inherit lower. New keys default to ['write']. The raw key is returned once at creation.

GET /v1/api-keys read

List your keys. Hashes only; never the raw value.

await client.apiKeys.list();
POST /v1/api-keys admin

Mint a new key. The response includes the raw key exactly once. Persist it now.

await client.apiKeys.create({
  name: 'ingest-bot',
  scopes: ['write'],
});
DELETE /v1/api-keys/{key_id} admin

Revoke. Idempotent. Subsequent requests bearing the revoked key fail at the authorizer.

await client.apiKeys.revoke('key_…');
/// STORAGE CONNECTIONS

Storage connections

Bring your own R2 or S3-compatible storage to extend capacity. Credentials live encrypted at rest under a per-account KMS key; nothing is logged or replayable.

GET /v1/storage-connections read

List configured connections.

POST /v1/storage-connections admin

Add a connection. The server runs HEAD / LIST / PUT / DELETE probes against the bucket before persisting.

POST /v1/storage-connections/preview write

Run the same probes without persisting. Useful for the onboarding wizard.

POST /v1/storage-connections/list-buckets write

Discovery: enumerate buckets visible to the credentials. Used during connection setup.

POST /v1/storage-connections/create-bucket admin

Discovery: create a new bucket using the credentials. Region-aware.

/// ACCOUNT

Account

The account record for the calling owner. Covers identity (name, email), the first-run welcome stamp, the avatar two-step upload, and the public profile fields that drive /u/<handle> share pages.

GET /v1/account read

Read the current account: display_name, email, plan, status, plus optional onboarded_at, avatar_url (1-hour signed), handle, bio, links, is_public. The first authenticated read after sign-up auto-derives a unique handle from the email local part.

await client.account.get();
PATCH /v1/account admin

Update display_name or email. Profile-shaped fields go through the dedicated profile route below.

await client.account.patch({ display_name: 'K' });
POST /v1/account/onboard admin

Stamp onboarded_at idempotently and (optionally) set display_name in one atomic write. Body is optional — empty body is "skip the welcome prompt, just mark me onboarded." Console calls this once after email verification.

await client.account.onboard({ display_name: 'Khaos Studio' });
POST /v1/account/avatar admin

Step 1 of avatar upload. Returns a presigned PUT URL plus a storage_key. Resize the image to 256×256 before sending the bytes — JPEG quality 85 is what the console emits. Allow-list: image/jpeg, image/png, image/webp; max 10 MB raw input.

const desc = await client.account.startAvatarUpload({
  content_type: 'image/jpeg',
  size_bytes: bytes.length,
});
await fetch(desc.url, { method: 'PUT', headers: desc.headers, body: bytes });
POST /v1/account/avatar/complete admin

Step 2. Confirms the bytes landed (server HEADs the storage_key) and atomically swaps avatar_storage_key on the account. The previous avatar object is best-effort deleted from R2. Returns the account with a fresh signed avatar_url.

await client.account.completeAvatarUpload(desc.storage_key);
DELETE /v1/account/avatar admin

Clear the custom avatar; UI falls back to initials. Idempotent.

await client.account.deleteAvatar();
PATCH /v1/account/profile admin

Update the public profile: handle, bio (≤280 chars), links (max 6, http(s) only), is_public. Renaming the handle atomically releases the old slug; surface a warning in your UI before submit. Conflicts return 409 handle_taken.

await client.account.updateProfile({
  handle: 'kimi',
  bio: 'photographer / filmmaker',
  links: [{ label: 'site', url: 'https://example.com' }],
  is_public: true,
});
/// USAGE

Usage

Plan utilization. Bytes and counts, split by Khaos-managed vs BYOK.

GET /v1/usage read

Returns total_assets, total_bytes, and the same broken out by managed_* and byok_*.

/// PUBLIC SPACES

Public spaces

Unauthenticated JSON endpoints used by the in-app viewer. For pasteable share URLs that render rich previews on Slack / iMessage / X, see Share host.

GET /v1/public/spaces/{short_id} none

If the space is public, returns the manifest with signed asset URLs. If protected, returns { requires_password: true }. Private spaces 404 (existence not leaked).

POST /v1/public/spaces/{short_id}/access none

Submit { password }; on match, returns the manifest. No tokens, no sessions; re-auth per request.

/// PUBLIC PROFILES

Public profiles

Unauthenticated portfolio endpoint. Returns 404 unless the owner has flipped is_public on. The 404 is intentionally indistinguishable from "handle doesn't exist" so unpublished profiles don't leak existence.

GET /v1/public/profiles/{handle} none

Returns display_name, bio, links, signed avatar_url, and the owner's public spaces (cover-decorated, sorted newest-first, capped at 24). Strictly less than the authenticated /v1/account projection — never leaks email, plan, status, or onboarded_at.

await client.publicProfiles.get('kimi');
/// SHARE HOST

share.khaosstorage.com

A dedicated subdomain that serves server-rendered HTML with og:* + twitter:* meta tags so pasted links render rich previews on Slack, iMessage, and X. The HTML bounces humans to the SPA via a JS redirect; crawlers index the metadata.

Space

share.khaosstorage.com/s/<short_id>

Profile

share.khaosstorage.com/u/<handle>

Cover image

…/s/<short>/cover or …/u/<handle>/cover — stable bytes for og:image (signed R2 URLs expire and social-card caches don't tolerate that).

The legacy api.khaosstorage.com/share/<short> and /share/u/<handle> URLs still work — they 301 to the canonical share host with a 24-hour cache. khs spaces url and the console's "copy share URL" button both return the canonical form.

/// OPENAPI

The spec.

Every route below is described in openapi.yaml. Drop it into Postman, Bruno, Insomnia, or Stoplight and you have a request playground.

https://raw.githubusercontent.com/khaos-studio/khaosstorage/main/openapi.yaml

CI fails on drift between openapi.yaml and the SAM template, so the spec stays in lock-step with the deployed surface.

/// SUPPORT

Get in touch.

Found a bug, want a feature, need access. We read everything.

Email k@khaos.studio.