# TwoZero API quick guide for LLM agents Base URL: https://twozero.ai OpenAPI JSON: https://twozero.ai/.well-known/openapi.json OpenAPI alias: https://twozero.ai/openapi.json LLMs TXT: https://twozero.ai/llms.txt LLM TXT alias: https://twozero.ai/llm.txt ## Auth - Required headers for most protected endpoints: - x-user-id: - x-access-key: - Repo-scoped file endpoints also require: - x-repo-id: - Public endpoints (no auth): /api/health, /api/repos/list-repos, /api/feed, /api/feed-ids - Reading the feed is public; PUBLISHING to it is admin-only. POST /api/feed/posts and POST /api/ai-gen/{id}/post refuse non-admins with 403 and reason ADMIN_ONLY. Do not retry that 403 — it is a permission decision, not a transient failure. The way back out is DELETE /api/feed/posts/by-generation/{generationId} — allowed to the post owner OR an admin. ## Rate limits - Global limiter is enabled. - Some high-throughput routes (AI generation, webhooks) have relaxed rate limits to support polling and multi-step flows. - Recommended client behavior: retry with exponential backoff on 429/5xx. ## Human-in-the-loop rules (must follow) - If auth/registration requires an email verification code and agent cannot read inbox: 1) trigger code delivery endpoint 2) pause execution 3) ask user to paste code in chat 4) continue only after code is provided - Never invent or brute-force codes; never skip verification steps. - If user asks to get/save access key for an already registered email and password is not explicitly provided: - DO NOT request password first. - Use code flow first: /api/users/login/code/request -> /api/users/login/code/verify. - If user asks "зарегистрируйся " without password: - treat registration as the same email-code flow - request code, ask user for code if inbox unavailable, verify code - on successful verify, user is created automatically when missing. - Do NOT open website/browser/UI by default for API tasks. - Use browser only if user explicitly asks for web UI actions/testing. - For local file uploads, ask user for an explicit file path from their computer before upload. ## Top 10 integration scenarios 1) Health check -> GET /api/health 2) Register/login by username (legacy) -> POST /api/users or POST /api/users/login 3) Get your profile -> GET /api/users/profile 4) Register/login by email code (preferred) -> POST /api/users/login/code/request + POST /api/users/login/code/verify 5) Search users by username -> GET /api/users (client-side filter by username) 6) Transfer balance to another user -> POST /api/users/balance/transfer 7) Discover public repos -> GET /api/repos/list-repos 8) Create repo -> POST /api/repos 9) Get my repos -> GET /api/repos/my-repos 10) Full repo + file index -> GET /api/repos/my-repos-full 11) Manage access keys -> /api/repos/{repoId}/access-keys 12) Share repo with user -> POST /api/repos/{repoId}/share-to/{userId} 13) Subscribe to public repo -> POST /api/repos/{repoId}/self-subscribe 14) Direct upload / AI / sync -> see OpenAPI ## Minimal request examples ### 1) Login (legacy external flow) POST /api/users/login Content-Type: application/json {"username":"agent_user"} Response (example): {"id":"","accessKey":"","username":"agent_user"} ### 1.0) Login by email + password (only when password is explicitly provided) POST /api/users/login/email Content-Type: application/json {"email":"test@test.com","password":""} Response (example): {"userId":"","email":"test@test.com","accessKey":"","repos":[...]} ### 1.01) Get access key by email code (preferred for "получи ключ для email") Step 1: request code POST /api/users/login/code/request Content-Type: application/json {"email":"booking404zero@gmail.com"} Step 2: verify code POST /api/users/login/code/verify Content-Type: application/json {"email":"booking404zero@gmail.com","code":"123456"} Response (example): {"userId":"","email":"booking404zero@gmail.com","accessKey":""} Registration note: - if email is new, verify step auto-creates user (no separate password registration required) - if email already exists, verify step logs in and returns same-style payload ### 1.0) Get balance & identity GET /api/users/balance x-user-id: x-access-key: Response (example): {"userId":"","username":"somebody2","email":"user@example.com","balance":102.5,"credits":{"included":7.25,"bonus":8,"purchased":87.25,"includedGranted":10,"includedExpiresAt":"2026-09-01T00:00:00.000Z","spendable":102.5},"avatarUrl":"https://wablabuda.com/avatars/.png","avatarHash":null,"storage":{"hub":{...},"archive":{...}}} - email is the account email (may be null for legacy accounts). avatarUrl is a resolved absolute URL (or null). avatarHash is reserved and currently null — use avatarUrl equality as the cache key. - credits splits the balance into its kinds (added 2026-08-01; credits.bonus added 2026-08-03, both additive). balance itself did NOT change meaning anywhere in the API: it is still what the user can spend right now, i.e. credits.included + credits.bonus + credits.purchased. A client that ignores credits keeps working exactly as before. - Spend order is included, then bonus, then purchased. Included credits come with a PRO subscription: they expire at credits.includedExpiresAt and do not roll over, cannot be transferred, and are never refunded in money. Bonus credits are added on top of a purchase (volume ladder and the PRO percentage): they never expire, have no cash value, cannot be transferred, and are cancelled if the purchase that generated them is refunded. Purchased credits never expire and are refundable. Show the kinds separately to a subscriber; showing only the total hides the fact that part of it has an expiry date. - credits.includedGranted minus credits.included is how much of this month's allowance is already spent. - For a user without a subscription credits.included is 0 and credits.includedExpiresAt is null. - storage carries BOTH stores and is additive (added 2026-07-31). hub = .tox components, has a plan ceiling. archive = generated results, their thumbnails and the inputs uploaded for them, no ceiling today (archive.limitBytes is null, meaning "no limit", not "unknown"). Never add the two numbers together — they are different stores with different promises. - storage.hub and storage.archive are independent and either may be null, meaning "could not be computed", NOT zero — hide that half of the UI rather than drawing an empty bar. Served from a 60-second server-side cache, so polling this endpoint stays cheap. ### 1.0b) Billing activity (what was charged, what was topped up) GET /api/billing/transactions?type=all|topups|generations&from=&to=&limit=50&offset=0 x-user-id: x-access-key: Response: {"items":[{"id","source":"lemon|crypto|generation","type":"topup|generation|refunded","date","amount","currency","description","model","prompt","deleted","chain","txHash"}],"total","hasMore","window":{"from","to"}} - amount is a signed number at full precision: a generation can cost a fraction of a cent and is never rounded. Positive credits the account, negative debits it. - This is an ACTIVITY list, not a statement. Prompt-enhancer spend and account-to-account credit transfers move the balance without writing a row anywhere, so the items do not sum to the balance and no running balance is returned. - Only CONFIRMED crypto top-ups appear: a pending claim carries a placeholder amount of 0 and was never credited. - A refunded generation appears once with amount 0. A deleted generation keeps its charge (with deleted:true) but returns no prompt. - The window defaults to the last 30 days and is clamped server-side to 92 days. ### 1.0c) Hub storage usage (how many bytes are taken, and out of how many) GET /api/users/stats x-user-id: x-access-key: Response: {"totalGenerations":412,"totalFeedPosts":7,"storage":{"usedBytes":29615224,"limitBytes":104857600,"limitLabel":"100 MB","fileCount":10,"ratio":0.282,"warnAtRatio":0.8,"state":"ok","counts":{"allVersions":true,"softDeleted":false,"deletedRepos":false,"previews":false}}} - The SAME storage object is also returned at the top level of GET /api/repos/my-repos-full, next to repos and files, and inside storage.hub of GET /api/users/balance. Desktop/TouchDesigner clients read it from there so their number matches the website; there is no separate storage endpoint and no second calculation. - Since 2026-07-31 this response also carries archive next to storage: the second store — generated results, their thumbnails and the inputs uploaded for them. It has no ceiling today (limitBytes null = no limit, not unknown), and it must never be added to the hub number. - storage may be null. null means "could not be computed", NOT zero — hide the remaining-space UI instead of drawing an empty or a full bar. - state is one of ok | warn | full and is computed server-side (warn from warnAtRatio, currently 0.8). Do not re-derive the threshold client-side, or two clients will disagree about when the hub is nearly full. - limitBytes comes from the plan RANK, not the slug: 104857600 (100 MB) without a subscription, 10737418240 (10 GB) on PRO. limitLabel is the human string for it — print it as-is. - What is counted: bytes the caller UPLOADED (files.uploadedBy, not what the repo owner owns), EVERY version of every file (each version is real bytes, not a pointer), in repositories that still exist. - What is NOT counted, on purpose: soft-deleted files (deleting a file IS the way to free space), files inside a soft-deleted repository (nothing in any UI leads to them, so they cannot be freed), and previews/thumbnails (the size of generated preview assets is recorded nowhere). So this number is smaller than the raw bucket footprint — expected, not drift. The counts object carries that signature so you can explain the figure instead of guessing. What happens when the hub is full: - Three endpoints put new bytes into a hub and all three check this quota: POST /api/files/confirm-upload (the last step of the upload flow), POST /api/files//clone and POST /api/explore//add-to-hub. Nothing else is gated — downloads, listings, sharing, metadata edits and preview uploads keep working with a full hub. - The refusal is HTTP 413 with body {"error":"Payload Too Large","code":"STORAGE_LIMIT","message":"HUB storage limit reached (100 MB). This file needs bytes and are already in use. Everything already in your HUB stays available and can be downloaded — delete a component to free up space.","storage":{"usedBytes","limitBytes","limitLabel","incomingBytes"}}. - Branch on code == "STORAGE_LIMIT", not on the text. Show message to the person as it is — it is written for them and already names the way out — and offer to delete a component. Never report the refusal as data loss: nothing was created, and everything already in the hub is still listed and still downloadable. - 413, not 403, on purpose: 403 in this product means "wrong plan or no access" and clients render it as "subscription required". A full hub is not that — the cure is deleting something, not buying something. - On confirm-upload the check runs on the REAL size read from storage, after the bytes are already in the bucket, and rolls the record back. Retrying the same s3Key will be refused again — free space first. On clone and add-to-hub the check runs BEFORE the copy, so a refusal leaves nothing behind. Re-adding a product that is already in your hub is idempotent and is never refused. - The refusal is behind a server-side switch that is currently OFF: as of today no client receives this 413, an overflow is only recorded server-side. Handle it as a case that can appear at any time; do not promise a user that a big upload WILL be blocked, and do not build a flow that depends on being refused. - Uploading through the website does not exist: files enter a hub from the TouchDesigner plugin (upload flow), from a clone, or from add-to-hub. So this 413 reaches the plugin and the store "add to hub" action, nowhere else. ### 1.1) Transfer balance POST /api/users/balance/transfer Content-Type: application/json x-user-id: x-access-key: {"toUsername":"somebody2","amount":"5.50"} Response (example): {"message":"Transfer completed","amount":5.5,"sender":{"userId":"","balance":94.5,"credits":{"included":7.25,"purchased":87.25}},"recipient":{"userId":"","balance":12.5,"credits":{"included":0,"purchased":12.5}}} - ONLY purchased credits can be transferred. Included credits that come with a PRO subscription have no cash value and stay with the account. So a subscriber whose balance is 10.00 but whose credits.purchased is 0 can spend that 10.00 on generations and cannot transfer any of it. - Because of that, the 400 "Insufficient balance" refusal for THIS endpoint reports the transferable (purchased) amount in both balance and transferable. It is the one place in the API where balance is not the spendable total; everywhere else balance means included + purchased. ### 1.2) Search users by username GET /api/users Response (example): [{"id":"","username":"somebody2"},{"id":"","username":"alice"}] Client-side search rule: - filter by substring/prefix on username (case-insensitive) - use selected username in transfer body: {"toUsername":"","amount":"10.00"} ### 2) Create repository POST /api/repos Content-Type: application/json x-user-id: x-access-key: {"username":"my-repo","rootAccessKey":"root-secret","isPublic":false} ### 2.1) Repository hub — full management Access roles with capabilities: - public: [list, read] — anonymous access for public repos (GET only) - read: [list, read] — list and download files - contribute: [list_own, read_own, write_own] — list/read/write own files only - write: [list, read, write] — list, download, upload files - root: [list, read, write, admin] — full management (keys, shares, settings) - owner: [list, read, write, admin] — implicit highest, can delete repo Public repos merge public capabilities with user role (e.g. contribute + public = list, read, write_own). Repo-scoped endpoints require x-repo-id header and repo auth (except self-subscribe). Get full repo + file index: GET /api/repos/my-repos-full x-user-id: x-access-key: Response: {"repos":[...],"files":{"":[{"path":"...","id":"...","versions":[...]}]},"storage":{"usedBytes":29615224,"limitBytes":104857600,"limitLabel":"100 MB","fileCount":10,"ratio":0.282,"warnAtRatio":0.8,"state":"ok","counts":{...}}} - storage is the hub usage of the caller, byte-for-byte the same object as in GET /api/users/stats (see 1.0c for what is counted and what is not). Additive field; may be null, which means "could not be computed", not zero. Get repo profile: GET /api/repos//profile x-user-id: x-access-key: x-repo-id: Update repo settings (root required): PUT /api/repos//settings x-user-id / x-access-key / x-repo-id headers {"username":"new-name","isPublic":true} Delete repo (owner only, soft delete): DELETE /api/repos/ x-user-id / x-access-key / x-repo-id headers Access keys (root required): - List: GET /api/repos//access-keys - Create: POST /api/repos//access-keys body: {"name":"ci-key","accessKey":"secret","accessLevel":"readWrite","expiresAt":"2026-12-31T00:00:00Z"} accessLevel enum: root | readWrite | read | contribute - Update: PUT /api/repos//access-keys/ body: {"name":"...","accessKey":"...","accessLevel":"...","expiresAt":null,"isActive":false} - Delete: DELETE /api/repos//access-keys/ (cannot delete the last root key) Sharing (root required): - List shares: GET /api/repos//shares[?status=pending|accepted|declined] Returns: [{userId, repoId, accessLevel, status, isActive, user:{id,username,email}}] - Share with user: POST /api/repos//share-to/ body: {"accessLevel":"write"} (default: read, also: contribute) Creates pending invitation + notification. Recipient must accept. - Remove share: DELETE /api/repos//share-to/ Self-subscribe to public repo (any authenticated user): POST /api/repos//self-subscribe x-user-id / x-access-key headers (no x-repo-id needed) Grants read access (immediately accepted). Returns 403 for private repos. ### 2.2) Hub links — public sharing Create public link (requires read access): - POST /api/repos//get-link → {"link":"https://twozero.ai/hub/repo_","code":"repo_","created":true} - POST /api/files//get-link → {"link":"https://twozero.ai/hub/file_",...} - POST /api/files//get-link?versionId= → {"link":"https://twozero.ai/hub/ver_",...} Idempotent: calling again returns same link with created=false. Code semantics: file_ is a LIVING link — it always resolves to the latest version of the logical file (repo + folder + name), whichever version uuid it was minted from. ver_ is PINNED to that exact version. Prefer minting file_ codes off the chain root (the version with parentFileId=null) so the URL stays canonical across uploads. Revoke public link: - DELETE /api/repos//get-link - DELETE /api/files//get-link[?versionId=] Resolve public link (NO AUTH): - GET /hub/ or GET /api/hub/ For repo: returns {type:"repo", repo:{...}, files:[{id,name,mimeType,size,version,downloadCode}]} (files[].id is the latest version; files[].downloadCode is a file_ code on the chain root) For file/ver: returns {type:"file"|"ver", file:{...}, repo:{...}, downloadUrl:"", expiresIn:3600} file_ resolves to the latest version; ver_ returns the exact pinned version. Browser gets styled HTML page; API clients get JSON. Direct download (NO AUTH): - GET /hub//download — 302 redirect to presigned S3 URL (file_ = latest, ver_ = pinned) ### 2.2b) Marketplace + subscriptions (public) Subscription plans: - GET /api/subscriptions/plans -> {plans:[{id,slug,name,rank,description,priceUsd}]} Your own subscription (auth): - GET /api/subscriptions/me -> {subscription: {status, planSlug, planName, renewsAt, endsAt, cancelled, grantsPlan, canManage} | null}. null when there is no Lemon Squeezy subscription behind the plan — including a plan an admin assigned by hand. No provider call; safe to ask for on every page load. - POST /api/subscriptions/cancel -> {subscription} — cancels through our API (no provider login involved). The provider only cancels at the end of the paid period: the answer comes back cancelled with endsAt set and access keeps granting until then. Idempotent: an already-cancelled subscription answers 200 with alreadyCancelled:true. 404 NO_SUBSCRIPTION when there is nothing to cancel; 409/503 (code CANCEL_FAILED) when the provider refuses/is unreachable — the message names the support fallback. - POST /api/subscriptions/resume -> {subscription} — undoes a cancellation while paid time is left. 409 NOT_CANCELLED when nothing is cancelled, 409 PERIOD_OVER once endsAt has passed (a new checkout is the only way back), 409/503 CANCEL-style (code RESUME_FAILED) on provider refusal (known limit: PayPal-paid subscriptions refuse API updates — use the portal). - POST /api/subscriptions/payment-method -> {url} — a fresh signed link to the provider's change-card form. Carries no login (unlike the portal link) and opens the form directly. 404 NO_SUBSCRIPTION / 503 PAYMENT_METHOD_UNAVAILABLE. POST so it is never prefetched; the link is short-lived, fetch per click. - POST /api/subscriptions/portal -> {url} — a fresh link into the Lemon Squeezy customer portal (secondary door: pause, invoices; cancel/resume/card live on the endpoints above). Always fetched from the provider per call — the stored copy is never served (measured: the signature dies in hours). ⚠️ the portal link performs a login into the LS account behind the customer email, which may not be the user's email. 404 (code NO_SUBSCRIPTION) when there is nothing to manage, 503 (code PORTAL_UNAVAILABLE) when the provider is unreachable — the message names the support address rather than handing over a dead link. POST so it is never prefetched. Explore store (public; optional auth personalizes lock state). Since 2026-08-07 product slugs are unique PER CREATOR (namespace), the canonical web address is /rudiments// and the permanent machine invariant of a product is its UUID (hub code product_): - GET /api/explore[?q=&tag=&plan=&limit=&offset=] -> {products:[{id,slug,url,title,tagline,tags,heroUrl,heroThumbUrl,heroKind,requiredPlan,locked}], total, limit, offset}. url = ABSOLUTE canonical product page (namespaced). q = case-insensitive substring over title+tagline+description+tags (NOT slug). tag = exact tag match. plan = "free" or a plan slug. limit default 60, max 200. offset paginates the current published set (not a stable cursor — the window can shift if products are (un)published between calls). - GET /api/explore// (canonical since 2026-08-07; anonymous ok; locked/entitled computed for the viewer) -> {product:{id,slug,url,title,tagline,description,tags,status,preview,hubLink,requiredPlan,entitled,locked,downloadAvailable,file,versions[],assets[],docManifest,creator,viewCount,downloadCount,publishedAt}}. versions[] entries carry a per-version `description` (2026-08-14, additive; null when the uploader left no note) — the release-note convention: the description sent with EACH hub upload describes what changed in THAT version. Increments viewCount. hubLink = permanent product_ hub link. A renamed product stays reachable via its old pair (slug history). Non-published listings (draft/archived) 404 for everyone EXCEPT admins, who get them back with preview:true and no viewCount increment. - GET /api/explore/ (LEGACY single-segment addressing; same response shape). When two creators share a slug the OLDEST product wins — pre-namespace links keep resolving to what they always pointed at. - GET /api/explore///download (canonical) and GET /api/explore//download (legacy) -> requires auth + matching subscription; {downloadUrl, expiresIn, product:{id,slug,url}, file:{id,name,size,version}}. product.id (uuid) + product.url (absolute) + file.version are the attribution stamps for downloaded components. 401 if anonymous, 403 (code PLAN_REQUIRED) if plan insufficient. - POST /api/explore///add-to-hub (canonical) and POST /api/explore//add-to-hub (legacy) -> requires auth + matching subscription; server-side clones the product file into the caller's auto-created "marketplace" repo. {added, alreadyInHub?, repo:{id,username}, file:{id,name,repoId,version}}. The clone's metadata.fromProduct carries the product UUID (older clones carry the slug). 403 (code PLAN_REQUIRED) if plan insufficient. 413 (code STORAGE_LIMIT) if the copy does not fit the caller's hub storage — checked BEFORE the copy, so nothing is left behind; re-adding a product already in the hub is idempotent and never refused. See 1.0c. - GET /api/hub/product_ (canonical, permanent — survives any rename) and /api/hub/product_ (legacy; oldest product wins on collision), both also under /hub/ and with /download -> product as a hub-style link for the TD client. Honors x-user-id/x-access-key for entitlement (free -> open to anyone, gated -> needs matching plan). Accept: text/html gets a 302 to the current /rudiments page (one permanent link serves both agents and humans). Resolve carries product:{id,slug,url} + upgradeUrl (= product.url) + file:{id,name,size,version,mimeType,description} where description (2026-08-14, additive) is the per-version release note of the version being served. Update check for an installed rudiment = this resolve (anonymous ok) -> compare file.version with the locally stamped Version; download stays plan-gated. Errors carry a machine code (INVALID_FORMAT|NOT_FOUND|PLAN_REQUIRED|AUTH_REQUIRED). - GET /api/users/search?q=&limit= (auth) — @handle autocomplete (2026-07-28). Case-insensitive substring match on username, excludes the caller, returns {users:[{id,username,avatarUrl}], query}. q shorter than 2 chars returns an empty list. Use this instead of GET /api/users, which returns the ENTIRE registry and exists only for legacy clients. ### 2.3) Notifications (polling) - GET /api/notifications[?unreadOnly=true&limit=50&offset=0] Returns: {notifications:[{id,type,message,metadata,isRead,actionStatus,from:{id,username,avatarUrl},createdAt}], total, unread} actionStatus: none (still actionable) | accepted | declined | expired. `expired` (2026-07-28) is COMPUTED for repo_share invites whose share was revoked — revoking flips UserRepos.isActive and leaves the row, so such an invite is no longer acceptable. Treat expired as terminal: do not offer accept/decline. - GET /api/notifications/unread-count → {unread: N} - PATCH /api/notifications//read — mark single as read - POST /api/notifications/read-all — mark all as read - POST /api/notifications//accept — accept actionable notification (e.g. repo_share). 400 {actionStatus} if already acted; 410 {code:"INVITE_REVOKED", actionStatus:"expired"} if the owner revoked the share in the meantime (it used to answer 200 and report success). - POST /api/notifications//decline — decline Notification types: repo_share (actionable), file_share, file_transfer. ### 2.4) Transfers — direct file sending Upload to public bucket (permanent URL): - POST /api/transfers/upload body: {"filename":"demo.txt","content_type":"text/plain","size":1234} Returns: {uploadUrl:"", publicUrl:"https://wablabuda.com/transfers/.txt", s3Key, expiresIn} Client PUTs binary to uploadUrl. File is permanently accessible at publicUrl. Send transfer + notification: - POST /api/transfers/send body: {"toUserId":"","s3Key":"...","publicUrl":"...","originalName":"demo.txt","mimeType":"...","size":1234,"message":"optional"} List transfers: - GET /api/transfers[?direction=received|sent&limit=50&offset=0] Mark downloaded: - PATCH /api/transfers//downloaded ### 2.5) File send-to — share existing repo file - POST /api/files//send-to/[?versionId=] Grants FileParticipant read access + notification. No repo access needed for recipient. Sender must have read access to the file (403 otherwise). - GET /api/files/shared-with-me[?limit=50&offset=0] Lists files shared directly with user (via FileParticipant/FileVersionParticipant). Returns: {files:[{fileId,name,mimeType,size,version,uploader:{id,username},accessType,grantedAt}], total} ### 2.6) File clone — server-side copy to another repo - POST /api/files//clone[?versionId=] body: {"targetRepoId":""} Server-side S3 copy (no re-upload). Requires read on source + write on target. If file with same name exists in target, creates new version. Returns: {file:{id,name,repoId,size,version,createdAt}} 413 (code STORAGE_LIMIT) if the copy does not fit YOUR hub storage — the copy counts against the caller, not the repo owner. Checked BEFORE the copy is made, so a refusal leaves nothing behind. See 1.0c. ### 2.7) Delete notification - DELETE /api/notifications/ — permanently removes notification Returns: {deleted:true} ### 3) Get presigned file upload URL POST /api/files/upload-url Content-Type: application/json x-user-id: x-access-key: x-repo-id: {"originalname":"image.png","mimetype":"image/png","folderPath":"assets"} Simple file-upload flow: 1. Call `POST /api/files/upload-url` and save `uploadUrl` + `s3Key`. 2. PUT raw file bytes to `uploadUrl` with matching Content-Type. 3. Call `POST /api/files/confirm-upload` with the same `s3Key`, `originalname`, `mimetype`, `folderPath`. - Step 2 must have finished: confirm-upload HEADs the object and returns 404 if nothing is stored at `s3Key`. - `size` in the body is a hint. The recorded size is the byte count storage reports. - 413 (code STORAGE_LIMIT) if the file does not fit the hub storage quota — checked on the real size, no record is created. Retrying the same `s3Key` gets the same answer; free space first. Show the returned `message` as-is and offer to delete a component; nothing already in the hub is affected. See 1.0c. 4. Save BOTH: - `fileId` from `confirm-upload` response - `s3Key` from the upload flow Public bucket URL rule: - You MAY try to build a direct bucket URL as `https://wablabuda.com/`. - Example: if `s3Key = "repo_uuid/abc-def.png"`, the guessed bucket URL is `https://wablabuda.com/repo_uuid/abc-def.png`. - But this direct bucket URL is only valid when the object is actually publicly exposed under `AWS_S3_PUBLIC_BUCKET_URL`. - `confirm-upload` by itself does NOT guarantee that the guessed bucket URL will resolve with 200. - There is no separate documented publish/finalize endpoint after `confirm-upload` that guarantees public exposure. Working URL fallback rule: - If you need a URL that works immediately after upload, call `GET /api/files/download-url/:fileId`. - Treat `downloadUrl` as the practical working URL when the guessed bucket URL returns 404 or is otherwise unavailable. - `downloadUrl` is temporary/presigned, so store `fileId` and refresh it by calling `GET /api/files/download-url/:fileId` again when needed. File previews (optional image/video thumbnails attached to a file version): - A file can have up to 10 previews (mix of images and videos), total budget 200 MB per file. - Previews are per-version: new file versions inherit ready previews from the previous version automatically in confirm-upload. - Two-phase upload flow for each preview: 1. POST /api/files/:fileId/previews/upload-url body: {"mimeType":"image/png","size":12345} -> {previewId, uploadUrl, rawS3Key} 2. PUT raw bytes to uploadUrl (direct to S3) 3. POST /api/files/:fileId/previews/:previewId/confirm (no body) -> transitions status awaiting_upload -> pending - The thumbworker process picks up pending rows, generates small (320px JPG) + large (1920px JPG) thumbs. For videos it also transcodes to 1080p H.264 MP4 with 192k AAC audio. - GET /api/files/:fileId/previews returns current list with presigned URLs (only when status=ready). Add ?includeFailed=1 to also get rows the worker gave up on (status=failed) — omitted by default so existing clients keep rendering only usable previews. - POST /api/files/:fileId/previews/:previewId/retry re-queues a failed preview (status back to pending). 409 if it is not failed, or if its raw upload is no longer stored — then upload it again. - DELETE /api/files/:fileId/previews/:previewId soft-deletes (marks superseded). PATCH /reorder accepts {order: [previewId, ...]}. ### 3.5) File management, versions & private search - GET /api/files/list/ (x-user-id, x-access-key, x-repo-id) — array of LATEST files in that repo folder; folderPath may be empty for root. Each item: {id, originalName, folderPath, uploadedAt, metadata (incl. description), size, uploadedBy, uploader:{id,username}, previews:[]}. - GET /api/files/versions/ (auth, or anonymous for public repos) — array of ALL versions of the file chain (repoId+folderPath+originalName), newest first. Each: {id, originalName, folderPath, version, size, uploadedAt, metadata, uploadedBy, isPublicLink, isLatest, parentFileId, previews:[]}. Lightweight per-file alternative to my-repos-full. NOTE the path is /versions/, NOT //versions. - PATCH /api/files//metadata (x-user-id, x-access-key, x-repo-id, write access) — merges metadata. Body {"metadata":{"key":"value"|null}} where a null value deletes that key. Also accepts a top-level {"description":"..."} that is folded into metadata.description (same slot confirm-upload writes to; top-level wins if both supplied). Returns {id, metadata}. 400 if neither a metadata object nor a top-level description is provided. - DELETE /api/files/ (x-user-id, x-access-key, x-repo-id, write access) — SOFT delete of that version; returns 204 No Content. The chain HEAD falls back to the previous version; the deleted version still resolves via GET /api/files/resolve/ (with requestedDeleted:true + requestedDeletedAt). There is no un-delete endpoint; re-upload to restore. - GET /api/files/search?q=&repo=&limit=&offset=<0>[&previews=1] (x-user-id, x-access-key) — server-side PRIVATE search across repos you can read (owned + accepted shares). Matches originalName + folderPath + metadata.description (case-insensitive substring), LATEST files only, ordered newest-first (uploadedAt DESC). q must be >=2 chars. repo= accepts a repoId OR a repo username (resolved server-side). Returns {files:[{id, repoId, repoUsername, ownerUsername, accessLevel, originalName, folderPath, size, uploadedAt, metadata, uploadedBy, isLatest, isPublicLink}], total, limit, offset}; previews[] is included per file only with previews=1. Use this instead of downloading my-repos-full and grepping locally. - description convention: on READ it always lives at file.metadata.description. On WRITE you may pass it either top-level (confirm-upload, and PATCH .../metadata) or as metadata.description — both land in the same place. Repository/history resolve flow (different use case): - `GET /api/files/resolve/:fileId` is for repository version/history metadata. - It resolves a file/version id to metadata such as requested version, latest version in branch, repo lineage and author. - It is NOT the public file URL and it is NOT the right endpoint for image inputs or "open link". Unambiguous file URL rules: - For uploads, the reliable post-upload identifiers are `fileId` and `s3Key`. - The guessed bucket URL `https://wablabuda.com/` is not guaranteed to work in all environments. - If you need a guaranteed working URL from the API, use `GET /api/files/download-url/:fileId`. - Do NOT use `/api/files/resolve/:fileId` as an image/file URL. It returns JSON metadata, not file bytes. - Do NOT use `/api/files/download/:fileId` as the canonical public URL. It is an authenticated download endpoint. - For patches, `ImageloaderTOP`, or image inputs: prefer a verified working URL. - Use direct bucket URL only after confirming it actually resolves; otherwise use fresh `downloadUrl`. - For `ImageloaderTOP`, pass the final verified URL, not `fileId`, not `resolve` URL, and not bare `s3Key`. ### 4) Start AI generation and poll result POST /api/ai-gen/v3/bytedance/seedream-4 Content-Type: application/json x-user-id: x-access-key: {"prompt":"cinematic portrait, 35mm, dramatic lighting"} Then poll: GET /api/predictions//result x-user-id: x-access-key: When the new generation edits an earlier one, record the link with an optional `_lineage` object next to the model inputs: {"prompt":"same scene, at night","_lineage":{"parentGenerationId":"","parentOutputIndex":0,"editVerb":"edit"}} `_lineage` is stripped before the payload reaches the model provider, so it never acts as a model input. It is ignored when the parent is unknown or not yours, and never blocks the generation. Read the resulting graph back with: GET /api/ai-gen//lineage (owner-only; returns flat nodes carrying parentGenerationId/parentOutputIndex/editVerb, plus anchorId/rootId) ### 5) Messages client (minimum flow) 1. Login (prefer /api/users/login/email when user gives email+password). 2. Save auth headers: - x-user-id = userId - x-access-key = accessKey 3. List dialogs: GET /api/messages/conversations?limit=50&offset=0 4. Read dialog messages: GET /api/messages/conversations//messages 5. Send message: POST /api/messages body: {"conversationId":"","text":"hello"} 6. If user says "send message to ": - resolve exact user via GET /api/users (username match) - DO NOT use repo ownerId/share metadata to identify message recipient - if multiple matches or no match -> ask user for clarification 7. Create conversation only with resolved participantIds from users directory. ### 5.2) Message attachments (exact format) Common file-attachment flow: 1. Upload file via files flow: /api/files/upload-url -> PUT -> /api/files/confirm-upload 2. Take returned file id from confirm-upload response. 3. Send message with attachment using EXACT shape: {"conversationId":"","text":"hello","attachments":[{"type":"file","refId":""}]} Important: - `attachments:[{"fileId":"..."}]` is invalid (missing type/refId). - `{"type":"file","fileId":"..."}` is invalid (must be refId). - valid `type` values: file, version, generation, patch, repo, s3_public_file. - for type=file/version/generation/patch/repo -> refId is required. ### 5.1) Check incoming messages (API-only) If user says: "залогинься , проверь входящие сообщения" Agent should: 1. Authenticate via documented auth flow (prefer email code flow when no password provided). 2. GET /api/messages/conversations 3. For each conversation, GET /api/messages/conversations//messages 4. Determine unread/incoming messages from response fields (no browser needed). 5. Return concise summary to user. ### 6) Chat balance transfer intents (must support) Natural-language intents to map: - "добавь в чат возможность отправки средств..." - "отправь usd" - "send usd" - "добавь поиск пользователей по username" Agent behavior: 1. Parse recipient username and amount from user text. 2. Normalize amount to 2 decimals (example: 10 -> "10.00"). 3. Currency words ("usd", "$", "баксов", "долларов") are aliases; API still expects only numeric amount. 4. If username or amount is ambiguous, ask a clarifying question before calling API. 5. Execute transfer via POST /api/users/balance/transfer with body: {"toUsername":"","amount":""} 6. Return user-friendly result with sender/recipient balances. 7. On errors (recipient not found / insufficient balance / validation), show API message and ask next action. 8. For "добавь поиск пользователей по username": - call GET /api/users - implement client-side username search/autocomplete - on select, prefill transfer target username ## Patch DAG guide - Base path: `/api/patches`. - All patch routes require `x-user-id` and `x-access-key` headers. - Patch body uses `ops` as an object keyed by stable operator ids chosen by the client. - Each operator node must include: - `OPtype`: exact class name from the operator catalog below. - `family`: resulting family (`DAT`, `TOP`, `VOP`). - `inputs`: array of upstream op ids. - `pars`: operator parameters object. - `tasks`: runtime task map; for create requests send `{}` or omit and server will initialize when needed. - `inputs` must reference existing op ids in the same patch and must not form a cycle. - Creating with `templateKey` auto-runs the patch after creation. Creating with custom `ops` does not auto-run; call a run endpoint explicitly. - Operator ids are the object keys inside `ops` (`promptSeed`, `promptPolish`, `renderImage` in the examples below). Run endpoints use these exact ids as `:opId`. - Use `thumbnailOpId` when you want clients to know which op produces the main visible result. - `family` must match the operator class output family. Wrong family values can break downstream resolution. ### Exact end-to-end flow for a custom patch 1. Build `ops` as an object keyed by operator ids. 2. POST `/api/patches` with `ops`. 3. Read `data.id` from the create response as `patchId`. 4. Start execution with one run endpoint. 5. Poll `GET /api/patches/` or `GET /api/patches//tasks` until the target tasks become `ready` or `failed`. 6. For image/video result patches, inspect `thumbnailOpId` and find the matching task/generation in patch detail. ### Exact end-to-end flow for a template patch 1. Optionally list templates via `GET /api/patches/templates`. 2. POST `/api/patches` with `templateKey` and optional `pars` overrides. 3. Read `data.id` from the create response as `patchId`. 4. Do not call `/run` immediately unless you explicitly want another run; template creation already triggers auto-run. 5. Poll `GET /api/patches/` or `GET /api/patches//tasks`. ### Create a custom patch POST /api/patches Content-Type: application/json x-user-id: x-access-key: { "label": "History to image", "thumbnailOpId": "renderImage", "ops": { "promptSeed": { "OPtype": "RandsentencesdbDAT", "family": "DAT", "inputs": [], "pars": { "source": "historyliked", "numitems": 3, "randsentperitem": 1, "limithistory": 100 }, "tasks": {} }, "promptPolish": { "OPtype": "AillmDAT", "family": "DAT", "inputs": [ "promptSeed" ], "pars": { "model": "openai/gpt-5.2-chat", "systemPrompt": "Write one concise production-ready image prompt in plain text.", "prompt": "Turn the source idea into one polished prompt for image generation.", "temperature": 0.7, "max_tokens": 400 }, "tasks": {} }, "renderImage": { "OPtype": "AiimageTOP", "family": "TOP", "inputs": [ "promptPolish" ], "pars": { "model": "wavespeed-ai/z-image/turbo", "aspect": "9:16", "randseed": true }, "tasks": {} } } } Response (example): { "data": { "id": "", "owner": "", "label": "History to image", "templateKey": null, "thumbnailOpId": "renderImage", "status": "draft", "config": {}, "ops": { "promptSeed": { "OPtype": "RandsentencesdbDAT", "family": "DAT", "inputs": [], "pars": { "source": "historyliked", "numitems": 3, "randsentperitem": 1, "limithistory": 100 }, "tasks": {} }, "promptPolish": { "OPtype": "AillmDAT", "family": "DAT", "inputs": [ "promptSeed" ], "pars": { "model": "openai/gpt-5.2-chat", "systemPrompt": "Write one concise production-ready image prompt in plain text.", "prompt": "Turn the source idea into one polished prompt for image generation.", "temperature": 0.7, "max_tokens": 400 }, "tasks": {} }, "renderImage": { "OPtype": "AiimageTOP", "family": "TOP", "inputs": [ "promptPolish" ], "pars": { "model": "wavespeed-ai/z-image/turbo", "aspect": "9:16", "randseed": true }, "tasks": {} } }, "createdAt": "", "updatedAt": "" } } ### Create from template with runtime parameter overrides POST /api/patches Content-Type: application/json x-user-id: x-access-key: { "templateKey": "image-from-history", "label": "Square variation", "pars": [ { "op": "renderImage", "par": "aspect", "value": "1:1" }, { "op": "promptPolish", "par": "temperature", "value": 0.4 }, { "op": "animate", "par": "duration", "value": 10 } ] } Override rules: - `pars` is an array of `{op, par, value}`. - `op` is the target operator id inside the template/patch. - `par` supports nested paths via dot notation. - Invalid op ids or unsafe paths are rejected with 400. ### Run requests Run whole patch: POST https://twozero.ai/api/patches//run x-user-id: x-access-key: Run one operator only: POST https://twozero.ai/api/patches//run/renderImage x-user-id: x-access-key: Run upstream chain until one operator: POST https://twozero.ai/api/patches//runTo/renderImage Run operator plus downstream chain: POST https://twozero.ai/api/patches//runFrom/promptPolish Run response (example): { "ok": true, "patchId": "", "opId": "renderImage" } ### Run patch variants - Run all ready operators: POST https://twozero.ai/api/patches//run - Run exactly one operator: POST https://twozero.ai/api/patches//run/ - Run upstream chain until operator: POST https://twozero.ai/api/patches//runTo/ - Run operator and downstream chain: POST https://twozero.ai/api/patches//runFrom/ - Inspect state: GET `/api/patches/` and GET `/api/patches//tasks`. ### Inspect patch state GET /api/patches/ x-user-id: x-access-key: Patch detail response (example): { "data": { "id": "", "label": "History to image", "status": "processing", "thumbnailOpId": "renderImage", "ops": { "promptSeed": { "OPtype": "RandsentencesdbDAT", "family": "DAT", "inputs": [], "pars": { "source": "historyliked", "numitems": 3, "randsentperitem": 1, "limithistory": 100 }, "tasks": { "1": { "status": "ready", "result": { "family": "DAT", "data": "sampled source text" } } } }, "promptPolish": { "OPtype": "AillmDAT", "family": "DAT", "inputs": [ "promptSeed" ], "pars": { "model": "openai/gpt-5.2-chat", "systemPrompt": "Write one concise production-ready image prompt in plain text.", "prompt": "Turn the source idea into one polished prompt for image generation.", "temperature": 0.7, "max_tokens": 400 }, "tasks": { "2": { "status": "processing" } } }, "renderImage": { "OPtype": "AiimageTOP", "family": "TOP", "inputs": [ "promptPolish" ], "pars": { "model": "wavespeed-ai/z-image/turbo", "aspect": "9:16", "randseed": true }, "tasks": {} } }, "tasks": [ { "id": "", "patchId": "", "opId": "promptSeed", "seq": 1, "taskType": "dbCompute", "status": "ready", "resultData": "sampled source text", "createdAt": "", "updatedAt": "" }, { "id": "", "patchId": "", "opId": "promptPolish", "seq": 2, "taskType": "apiCall", "status": "processing", "createdAt": "", "updatedAt": "" } ] } } GET /api/patches//tasks x-user-id: x-access-key: Tasks response (example): { "data": [ { "id": "", "patchId": "", "opId": "promptSeed", "seq": 1, "taskType": "dbCompute", "status": "ready", "resultData": "sampled source text" }, { "id": "", "patchId": "", "opId": "promptPolish", "seq": 2, "taskType": "apiCall", "status": "failed", "error": "OpenRouter upstream timeout" } ] } Task status interpretation: - `queued`: planner accepted the task and it is waiting to start. - `processing` or `sent`: external call or worker execution is in progress. - `ready`: task completed successfully and downstream ops may become runnable. - `failed`: execution failed; inspect `error`. - `cancelled`: task was manually/admin cancelled. Patch execution rules: - Custom patch creation returns `status: "draft"` until you call one of the run endpoints. - Template patch creation can start executing immediately after create because server calls `handleRunAll(...)` automatically. - If you need to rerun an existing patch, call a run endpoint again on the same `patchId`. - To target a single output op, prefer `/runTo/` or `/run/` depending on whether upstream dependencies must also be re-executed. Common create/run errors: - `400 Cycle detected ...`: your `inputs` graph contains a cycle. - `400 Operator "" not found in patch ops`: `pars` override points to missing op id. - `404 Template "" not found`: invalid or inactive `templateKey`. - `404 Operator not found`: run endpoint received unknown `:opId`. - `403 Access denied`: patch belongs to another user. Agent rules for patch authoring: - Always keep `ops` as an object, not an array. - Always use exact operator class names in `OPtype`. - Always keep `inputs` ids aligned with object keys in the same payload. - For custom patches, do not tell the user execution has started until a run endpoint returns `{ "ok": true, ... }`. - After create/run, poll patch state instead of assuming sync completion. ### Patch example: image to video { "label": "Image to video", "thumbnailOpId": "animate", "ops": { "prompt": { "OPtype": "TextconcatDAT", "family": "DAT", "inputs": [], "pars": { "prefix": "Cinematic product shot of a chrome robot in rain. ", "separator": "\n", "suffix": " Keep the composition minimal." }, "tasks": {} }, "inputImage": { "OPtype": "ImageloaderTOP", "family": "TOP", "inputs": [], "pars": { "url": "https://example.com/reference.png", "requireHttp": true, "allowDataUrl": false }, "tasks": {} }, "animate": { "OPtype": "AiVideoVOP", "family": "VOP", "inputs": [ "prompt", "inputImage" ], "pars": { "model": "bytedance/waver-1.0", "aspect": "16:9", "duration": 5, "genAudio": false, "randseed": true }, "tasks": {} } } } ### Operator classes - AiimageTOP [family=TOP, taskType=apiWebhook] - Summary: Generates an image through the Wavespeed-compatible API and resolves on webhook callback. - Inputs: DAT, TOP - Output: TOP -> Public image URL from the first output. - Parameters: - model: type=string, default="wavespeed-ai/z-image/turbo" - aspect: type=menu, default="9:16"; enum=9:16, 1:1, 16:9, 21:9 - prompt: type=string, default="" - seed: type=int, default=-1 - randseed: type=bool, default=true - Note: Combines DAT inputs with pars.prompt into a final prompt. - Note: Consumes TOP inputs as image/reference inputs when the model supports them. - Note: Chooses size or aspect_ratio from the model schema, with a fallback size heuristic. - AillmDAT [family=DAT, taskType=apiCall] - Summary: Calls OpenRouter chat completions and returns text output. - Inputs: DAT, TOP, VOP - Output: DAT -> Plain text response extracted from the first completion choice. - Parameters: - prompt: type=string, default="" - systemPrompt: type=string, default="" - model: type=string, default="openai/gpt-5.2-chat" - temperature: type=float, default=0.7 - max_tokens: type=int, default=10000 - Note: DAT inputs are joined into user text. - Note: TOP inputs are attached as image_url blocks. - Note: VOP inputs are converted to thumbnail image URLs when possible because video files are not sent directly to LLMs. - AiVideoVOP [family=VOP, taskType=apiWebhook] - Summary: Generates a video through the Wavespeed-compatible API and resolves on webhook callback. - Inputs: DAT, TOP - Output: VOP -> Public video URL from the first output. - Parameters: - model: type=string, default="bytedance/waver-1.0" - aspect: type=menu, default="9:16"; enum=9:16, 1:1, 16:9, 21:9 - duration: type=float, default=5 - genAudio: type=bool, default=false - seed: type=int, default=-1 - randseed: type=bool, default=true - Note: Concatenates all DAT inputs into one prompt. - Note: Consumes TOP inputs as image/reference inputs when the selected model schema supports them. - Note: Normalizes aspect and duration against the model request schema when available. - GetrandsentensesDAT [family=DAT, taskType=compute] - Summary: Samples random sentences from connected DAT inputs in-memory. - Inputs: DAT - Output: DAT -> Joined text made from up to N sampled sentences. - Parameters: - Numsentences: type=int, default=3 - numsentences: type=int, default=3 - Note: Supports both Numsentences and numsentences parameters for compatibility. - Note: Resolves immediately without DB or external API. - ImageloaderTOP [family=TOP, taskType=compute] - Summary: Validates and passes through a direct image URL. - Inputs: none - Output: TOP -> The validated URL from pars.url. - Parameters: - url: type=string, default="" - requireHttp: type=bool, default=true - allowDataUrl: type=bool, default=false - Note: Rejects empty URL values. - Note: Can require http/https and optionally allow data:image URLs. - LoadgenerationidDAT [family=DAT, taskType=dbCompute] - Summary: Loads an existing generation by id and returns its original prompt text. - Inputs: DAT - Output: DAT -> The generation input.prompt string, or empty string when not found. - Parameters: - generationId: type=string, default="" - Note: Uses the first non-empty DAT input as generation id, otherwise falls back to pars.generationId. - Note: Reads from AIGeneration and returns prompt text even for failed generations. - LoadgenerationidTOP [family=TOP, taskType=dbCompute] - Summary: Loads an existing generation by id and returns a public image URL or a video thumbnail URL. - Inputs: DAT - Output: TOP -> Public original image URL or first thumbnail URL for videos. - Parameters: - generationId: type=string, default="" - Note: Uses the first non-empty DAT input as generation id, otherwise falls back to pars.generationId. - Note: Fails if the generation is missing or not completed. - OgcreatesentencedbDAT [family=DAT, taskType=dbCompute] - Summary: Builds a censored prompt seed from liked/history/feed generations in the database. - Inputs: none - Output: DAT -> Prompt text assembled from sampled source generations. - Parameters: - source: type=menu, default="historyliked"; enum=historyliked, feedliked, feed - numitems: type=int, default=3 - randsentperitem: type=int, default=2 - limithistory: type=int, default=50 - Note: Reads AIGeneration rows scoped to the authenticated user context. - Note: Samples prompts and injects fixed censorship instructions into the final text. - RandsentencesdbDAT [family=DAT, taskType=dbCompute] - Summary: Samples random sentences from prompt history/feed data in the database. - Inputs: none - Output: DAT -> Joined random sentences from sampled generations. - Parameters: - source: type=string, default="historyliked" - numitems: type=int, default=3 - randsentperitem: type=int, default=1 - limithistory: type=int, default=200 - Note: Reads AIGeneration rows scoped to the authenticated user context. - Note: Supports historyliked, feedliked and feed sources. - TextconcatDAT [family=DAT, taskType=compute] - Summary: Concatenates DAT inputs with optional prefix, suffix and separator. - Inputs: DAT - Output: DAT -> Concatenated text. - Parameters: - separator: type=string, default="\n" - prefix: type=string, default="" - suffix: type=string, default="" - Note: Resolves immediately without DB or external API. ### Discovery - Machine-readable operator catalog is included in OpenAPI under top-level `x-twozero-patch-operators`. - Patch request/response schemas and examples are also included in OpenAPI. ### Agent quick-intent shortcut If user asks in natural language like: "https://twozero.ai/llms.txt - сделай клиент сообщений для test@test.com (уже зарегистрирован)" then agent should: - fetch llms.txt + OpenAPI - use code login flow first when password is not explicitly provided - build a messages client using only documented endpoints - avoid undocumented/private routes - when targeting a username in chat, resolve via GET /api/users only (never from repos data) If user asks like: "https://twozero.ai/llms.txt - зарегистрируйся на мою почту и сделай клиент сообщений с отправкой файлов" then agent should additionally: - handle email verification in human-in-the-loop mode (request code -> ask user -> continue) - if no inbox access, explicitly ask user to provide verification code in chat - ask user which local file path to upload, then use only documented upload flow - explain that file content is taken only from user-provided local path If user asks like: "https://twozero.ai/llms.txt - получи и сохрани ключ для юзера booking404zero@gmail.com - он уже зарегистрирован" then agent should: - call /api/users/login/code/request with this email - if inbox is not accessible, ask user to paste code from email - call /api/users/login/code/verify with email+code - return/save userId + accessKey - not request password unless user explicitly provided one If user asks like: "https://twozero.ai/llms.txt - зарегистрируйся mail@mail.com" then agent should: - use the same email code flow as login - call /api/users/login/code/request with this email - request code from user if inbox is not accessible - call /api/users/login/code/verify - return/save userId + accessKey - not ask for password by default If user asks like: "отправь юзеру dznm сообщение " then agent should: - call GET /api/users and resolve exact username dznm - check existing conversations for that resolved userId - create conversation with participantIds only if needed - send message via POST /api/messages - report conversationId + messageId If user asks like: "https://twozero.ai/llms.txt - отправь username 10 usd" then agent should: - treat this as immediate transfer intent - execute POST /api/users/balance/transfer after quick validation - ask confirmation only when command is ambiguous If user asks like: "twozero.ai/llms.txt - залогинься x.lezius@gmail.com, проверь входящие сообщения" then agent should: - execute API-only flow (auth + messages endpoints) - not open the website/browser unless explicitly requested ## Owner Dashboard (private, owner-only) - /api/dashboard/* endpoints are restricted to an email allowlist. - Default allowed: znamdd@gmail.com, x.lezius@gmail.com, krichite@gmail.com. - Override via OWNER_DASHBOARD_EMAILS env var (comma-separated). - Non-allowed users receive 403 on all dashboard routes. - Key endpoints: - GET /api/dashboard/overview/access - check access - GET /api/dashboard/overview/data - unified KPI snapshot (web + MCP + money + infra) - GET /api/dashboard/overview/generators - top accounts by generation activity - GET /api/dashboard/analytics/dashboards - PostHog dashboards proxy ## Notes - IDs are UUID strings. - Keep backward compatibility with listed external endpoints. - For complete schema and operations, always parse OpenAPI JSON.