@officexapp/vidfarm-devcli 0.21.50 → 0.21.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -399,7 +399,7 @@ You may be running as the **in-web AI chat** (the /editor copilot, the chat dock
399
399
  |---|---|---|
400
400
  | `references/core-workflows.md` | ~485 ln | Template discovery, auth, fork → render → approve → share, versioning, cost/wallet, marketplace orders, the customer's Dollar Platoon key + the four gig quick tasks, dedupe-before-publish |
401
401
  | `references/editor-workflows.md` | ~650 ln | **The biggest read.** Timeline editing, decompose, captions, transitions, motion, AI placement, the caption standard, the editor action verbs |
402
- | `references/assets-and-sourcing.md` | ~275 ln | **Google search for footage** (the query playbook), raws hunts, clip scanning, My Files, recurring characters, downloading media off a URL, social recycle |
402
+ | `references/assets-and-sourcing.md` | ~390 ln | **Google search for footage** (the query playbook), raws hunts, clip scanning, My Files, **uploading (presign → PUT → finalize)**, recurring characters, downloading media off a URL, social recycle |
403
403
  | `references/agent-included-imagegen.md` | ~70 ln | **Free custom art without spending.** Image generation the AGENT's own subscription already includes (Antigravity / Gemini CLI, Codex / ChatGPT — Claude Code has none): why it is allowed in cost mode `minimize`, `vidfarm agent-image --check` / the brief command, where it sits on the asset ladder, and the honest limits |
404
404
  | `references/browser-harness.md` | ~85 ln | **Free-plan sourcing, and cost mode `minimize`.** Driving the user's own Chrome with browser-harness as the $0 substitute for the paid video-search / image-search / news-search / download-video primitives: setup, the browse recipes, the raws collection loop, the interactive hand-off, and the licensing/etiquette limits |
405
405
  | `references/automation-and-local-dev.md` | ~520 ln | **Big.** The whole `vidfarm` command table, REST automation, scripting/bulk mode, `HARNESS.md`, local serve loop, skill packs |
@@ -481,7 +481,7 @@ The File Index above says what each file *is*; this says which one a given ask m
481
481
  5b. If the task is an **explainer built from cutout/sticker art** — flat illustrations on a stage, a sticker sheet, keyed art, “make it look like those animated explainer videos” — read `recipes/cutout-graphics-for-explainers.md`. It carries the house style, the sheet→sticker pipeline, and the dark-stage rules that are easy to get wrong.
482
482
  5c. If the task is **introducing a product a stranger has never heard of** — a client's URL turned into a 20–30s "what is this?" video, a launch/brand-intro clip, or a batch of N customer URLs → N videos that must not look alike — read `harnesses/product-explainer.HARNESS.md`. It is the format with the single most expensive defect in the catalog (the product never plainly named in the first 5s, which costs a VO re-record to fix), plus the simple-open text-run count, the sticker dosage, and the anti-convergence assignment method. Use `product-demo` instead when you actually have the UI on screen.
483
483
  6. If the task explicitly asks for a primitive or needs specialized generation/transcription work, read `references/primitives.md`.
484
- 7. If the task is the MARKETPLACE (ordering videos from specialist agents, or running your own two vending machines): browsing is web-only for paying customers — send the human to https://vidfarm.cc/marketplace, never render it locally. **`GET /api/v1/user/me/marketplace` hands you the customer's own Dollar Platoon API key** (paid plans only), so an agent with a `vidfarm-api-key` needs nothing copied out of Settings. Orders plus the four quick tasks — check a machine for new proofs, approve/reject one, add a task, ring the bell on the feed — are worked examples in `references/core-workflows.md` (§ Marketplace, § Your Dollar Platoon key). Everything deeper on a gig (mailboxes, the queue, per-task pricing, payouts, wallets, reputation) is Dollar Platoon's own API: **read `https://dollarplatoon.com/skill.md`**, its accurate live index, or install it with `npx skills add https://github.com/OfficeXApp/dollarplatoon-skill` — the same way FlockPoster work beyond scheduling needs `npx skills add https://github.com/OfficeXApp/flockposter-skill`. Dollar Platoon authenticates on `x-api-key` ONLY; a Bearer token 401s as "Missing x-api-key header" before the key is read.
484
+ 7. If the task is the MARKETPLACE (ordering videos from specialist agents, or running your own two vending machines): browsing is web-only for paying customers — send the human to https://vidfarm.cc/marketplace, never render it locally. **`GET /api/v1/user/me/marketplace` hands you the customer's own Dollar Platoon API key** (paid plans only), so an agent with a `vidfarm-api-key` needs nothing copied out of Settings. Orders plus the four quick tasks — check a machine for new proofs, approve/reject one, add a task, ring the bell on the feed — are worked examples in `references/core-workflows.md` (§ Marketplace, § Your Dollar Platoon key). **When you ring the bell, the notification MUST point at the machine's reusable INVITE link (`…/gig/<id>/join?invite=<token>`)** — that is what a gigworker taps to join and pitch. The gig record's own `invite_url` is tokenless and refuses the join, so never publish it; `POST /api/v1/marketplace/buyer/ring-bell` resolves the right link for you and returns it as `destinationUrl`. Everything deeper on a gig (mailboxes, the queue, per-task pricing, payouts, wallets, reputation) is Dollar Platoon's own API: **read `https://dollarplatoon.com/skill.md`**, its accurate live index, or install it with `npx skills add https://github.com/OfficeXApp/dollarplatoon-skill` — the same way FlockPoster work beyond scheduling needs `npx skills add https://github.com/OfficeXApp/flockposter-skill`. Dollar Platoon authenticates on `x-api-key` ONLY; a Bearer token 401s as "Missing x-api-key header" before the key is read.
485
485
 
486
486
  ## Non-Negotiables
487
487
 
@@ -489,6 +489,7 @@ The File Index above says what each file *is*; this says which one a given ask m
489
489
  - Do not manipulate composition HTML by string concatenation. Parse, edit, and re-serialize the DOM.
490
490
  - Do not call the renderer directly. Rendering goes through `POST /api/v1/compositions/:forkId/render`.
491
491
  - Do not store provider secrets in composition HTML or JSON.
492
+ - **Never send media bytes inside an API request body — upload with a presigned S3 PUT.** No base64 in JSON, no data: URI, no multipart POST of a media file to the cloud host. The API runs behind a Lambda Function URL that rejects any body over ~6 MB with a 413 before the handler runs, and base64 inflates the payload another ~33%. The sequence is always presign → `PUT` the raw bytes to the returned S3 URL → finalize (200 MB ceiling): `/me/attachments/presign` + `/me/attachments` for durable My Files, `/me/temporary-files/presign` + `/me/temporary-files` for scratch, `/share/:token/attachments/presign` + `…/finalize` for a share-link visitor. Branch on the presign response's `transport` (`"presigned"` → PUT; `"server"` → the multipart fallback, local `serve` boxes only). Already have a URL? Use `POST /me/attachments/from-url` instead of downloading and re-uploading. devcli does all of this: `put-file`, `upload`, `approve --video`, `shared put`. See `references/assets-and-sourcing.md` → *Uploading media: presign → PUT → finalize*.
492
493
  - Treat `forkId` as an unguessable bearer token for read access.
493
494
  - Submission routes are generally not idempotent. Especially for renders and expensive primitives, check status before retrying.
494
495
  - In the web editor, use CSS/declarative motion only. Script-bearing HTML is stripped or rejected there.
@@ -201,9 +201,50 @@ Each user has a persistent **My Files** filesystem — their own uploaded videos
201
201
 
202
202
  - **List** — `GET /api/v1/user/me/attachments` → `{ attachments: [{ id, fileName, contentType, sizeBytes, folderPath, viewUrl, createdAt }], folders: [string] }`. The `viewUrl` is a durable URL you can drop into a composition layer or pass into a primitive route. devcli: `vidfarm files [--folder <path>]`.
203
203
  - **Read one** — resolve it from the list and stream/read its `viewUrl`. devcli: `vidfarm get-file <id> [dest]` (add `--print` to print text contents of md/txt/csv/json inline). For images/video/audio/pdf, reference the `viewUrl` as media — you can't read their bytes as text.
204
- - **Write into My Files** — `POST /api/v1/user/me/attachments/upload` (multipart `file` + optional `folder_path`) persists a file into the library. devcli: `vidfarm put-file <localFile> --folder <path>`, or pipe/inline text without a temp file: `echo "…" | vidfarm put-file --stdin --as OFFER.md --folder acme` / `vidfarm put-file --content "…" --as OFFER.md`. This is how an agent saves Getting Started / consultation context (OFFER.md — or OFFER_<NAME>.md for a multi-offer director — plus awareness-levels.md, persuasive-angles.md, ad-hooks.md) or drops a logo/product demo into a namescoped folder. In the web editor copilot the same capability is the **`browse_files` write action** (`action=write` with `file_name` + `content` + optional `folder_path`), which accepts text files (md/txt/csv/json/srt/vtt).
204
+ - **Write into My Files** — devcli: `vidfarm put-file <localFile> --folder <path>`, or pipe/inline text without a temp file: `echo "…" | vidfarm put-file --stdin --as OFFER.md --folder acme` / `vidfarm put-file --content "…" --as OFFER.md`. This is how an agent saves Getting Started / consultation context (OFFER.md — or OFFER_<NAME>.md for a multi-offer director — plus awareness-levels.md, persuasive-angles.md, ad-hooks.md) or drops a logo/product demo into a namescoped folder. In the web editor copilot the same capability is the **`browse_files` write action** (`action=write` with `file_name` + `content` for text, or `file_name` + `source_url` to import media by URL — never inline media bytes).
205
+
206
+ By raw REST, **always use the three-step presigned path** (see [Uploading media: presign → PUT → finalize](#uploading-media-presign--put--finalize-the-only-correct-way)): `POST /me/attachments/presign` → `PUT` the raw bytes to the returned S3 URL → `POST /me/attachments` to finalize (accepts `notes`). `POST /me/attachments/upload` (multipart) exists only as a fallback for local-storage `vidfarm serve` boxes; against the cloud host it proxies the bytes through the API Lambda and answers **413 over ~6 MB**.
207
+
205
208
  - **`vidfarm upload` is different** — it targets the **ephemeral** temporary-files store (`POST /api/v1/user/me/temporary-files/upload`) for a throwaway durable URL to drop straight into a composition or approved post. Use `put-file` when you want the asset to persist in the user's My Files library; use `upload` for one-shot media you just need a URL for. Namescope scratch uploads under a **`temp/` folder** (`vidfarm upload clip.mp4 --folder temp`) so they stay in one purgeable place. Better still, if you're on a `vidfarm serve` box, skip the upload entirely and reference the file off disk with `place --src ./clip.mp4` (see "Local file paths as media").
206
209
 
210
+ ### Uploading media: presign → PUT → finalize (the only correct way)
211
+
212
+ **Rule: media bytes never travel inside an API request body.** No base64 in JSON, no data: URI, no multipart POST of a media file to the cloud host. Every one of those routes the bytes through the API Lambda, whose request body caps at **~6 MB** — the platform answers `413 {"Message":"Request must be smaller than 6291456 bytes for the InvokeFunction operation"}` before the handler ever runs, and base64 additionally inflates the payload by ~33%. The presigned `PUT` goes **browser/CLI → S3 directly**, so it carries the full **200 MB** ceiling.
213
+
214
+ The same three-step shape works on all three stores — only the route prefix changes:
215
+
216
+ | Store | Presign | Finalize | Lifetime |
217
+ |---|---|---|---|
218
+ | **My Files** (durable) | `POST /api/v1/user/me/attachments/presign` | `POST /api/v1/user/me/attachments` | permanent |
219
+ | **Temp** (scratch) | `POST /api/v1/user/me/temporary-files/presign` | `POST /api/v1/user/me/temporary-files` | 30-day TTL |
220
+ | **Share link** (visitor, no account) | `POST /api/v1/share/:token/attachments/presign` | `POST /api/v1/share/:token/attachments/finalize` | owner's |
221
+
222
+ ```bash
223
+ # 1. presign — announce the file, get a one-hour S3 PUT target
224
+ curl -sS -X POST https://vidfarm.cc/api/v1/user/me/attachments/presign \
225
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" -H 'content-type: application/json' \
226
+ -d '{"file_name":"hero.mp4","content_type":"video/mp4","size_bytes":41943040,"folder_path":"acme-skincare"}'
227
+ # → { "transport":"presigned", "attachment_id":"…", "storage_key":"…", "view_url":"…",
228
+ # "upload": { "method":"PUT", "url":"https://…s3…", "headers":{"content-type":"video/mp4"}, "expires_in_seconds":3600 } }
229
+
230
+ # 2. PUT the raw bytes straight to S3 with EXACTLY the returned headers (never re-encode)
231
+ curl -sS -X PUT "$UPLOAD_URL" -H 'content-type: video/mp4' --data-binary @hero.mp4
232
+
233
+ # 3. finalize — record the attachment; echo back attachment_id + storage_key verbatim
234
+ curl -sS -X POST https://vidfarm.cc/api/v1/user/me/attachments \
235
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" -H 'content-type: application/json' \
236
+ -d '{"attachment_id":"…","file_name":"hero.mp4","content_type":"video/mp4","size_bytes":41943040,"storage_key":"…","folder_path":"acme-skincare","notes":"Acme hero cut, 40s"}'
237
+ # → 201 { "ok": true, "attachment": { "viewUrl": "https://…" } } ← viewUrl is the durable media URL
238
+ ```
239
+
240
+ Notes that matter:
241
+ - The presign response's **`transport`** decides the path. `"presigned"` → PUT to `upload.url`. `"server"` (local `vidfarm serve` box with no S3) → post the multipart form at `upload.url` instead. Branch on that field; never assume.
242
+ - **Send `upload.headers` unchanged** on the PUT. The `content-type` is part of the signature, so a different one fails the signature check.
243
+ - Finalize **re-derives** the storage key from your ids and rejects a mismatch, so pass `attachment_id` + `storage_key` exactly as presign returned them.
244
+ - Set `notes` at finalize time — it is vector-embedded, so the file is findable by meaning later.
245
+ - **Already have a URL?** Do not download-and-re-upload. `POST /me/attachments/from-url` `{ source_url, file_name?, folder_path?, notes? }` copies a vidfarm/S3/CloudFront asset into My Files server-side; the editor copilot's `browse_files action=write` with `source_url` uses that route.
246
+ - devcli does all of this for you: `vidfarm put-file` (durable), `vidfarm upload` (temp), `vidfarm approve --video ./final.mp4` (durable + approve), `vidfarm shared put` (share-link visitor). Prefer the devcli on desktop.
247
+
207
248
  ### Metadata notes + vector search (find assets by meaning)
208
249
 
209
250
  Every My Files entry carries an optional **`notes`** field — free-form metadata describing what the file *is*, who/what it depicts, and when to use it. Notes are **vector-embedded** on save (same BYOK embedding seam as the raws library: gemini → openai key auto-pick; no key fail-softs to keyword-only), so the library is searchable by *meaning*, not just filename:
@@ -307,6 +348,9 @@ vidfarm gigs add-task --task "60s explainer for acme.com" --price 0.50 --tags sh
307
348
  --assets-link "$READ_LINK" --upload-link "$DROP_LINK" --subfolder task-014 \
308
349
  --proof "MP4 in your subfolder" --proof "public MP4 url in the proof body"
309
350
  vidfarm gigs ring-bell --title "Acme wants 7 shorts this week" --machine custom_requests
351
+ # ↑ the bell publishes that machine's REUSABLE INVITE LINK (…/join?invite=<token>) as the
352
+ # notification's destination, so a gigworker who taps it joins and pitches at once. A
353
+ # tokenless ".../gig/<id>/join" is refused at the door — never publish one.
310
354
  vidfarm gigs tasks # what is still unclaimed in the FIFO queue
311
355
  vidfarm gigs proofs --status pending # what came back, with the playable link
312
356
  vidfarm gigs approve PRF_01H… --feedback "Great hook, keeping it."
@@ -306,7 +306,7 @@ The licensed harness also carries the **generative build workflow** guidance (ch
306
306
  | `vidfarm files [--folder <path>]` | `GET /api/v1/user/me/attachments` | list My Files assets + folders |
307
307
  | `vidfarm files --search "…" [--folder <path>]` | `POST /api/v1/user/me/attachments/search` | find My Files assets by MEANING (keyword + vector over name/folder/notes) |
308
308
  | `vidfarm get-file <id> [dest] [--print]` | (resolve id → view_url, then stream/print) | read one My Files asset |
309
- | `vidfarm put-file <file> [--folder <p>] [--as <name>] [--content/--stdin] [--notes "…"]` | `POST /api/v1/user/me/attachments/upload` | write into My Files (persistent) |
309
+ | `vidfarm put-file <file> [--folder <p>] [--as <name>] [--content/--stdin] [--notes "…"]` | presign → S3 PUT → finalize (`/me/attachments/presign` + `/me/attachments`) | write into My Files (persistent). Direct to S3, so up to **200 MB**; multipart is only the local-`serve` fallback |
310
310
  | `vidfarm annotate-file <id\|name> --notes "…"` | `PATCH /api/v1/user/me/attachments/:id` | set metadata notes on one My Files entry (vector-embedded) |
311
311
  | `vidfarm raws scan <video> [--range MM:SS-MM:SS] [--duration <s>] [--aspect 9:16] [--no-text] [--prompt "…"]` | (LOCAL: local ffmpeg + local claude/codex agent → `~/.vidfarm` SQLite) | hunt long-form video into short-form raws on this machine |
312
312
  | `vidfarm raws scan --cloud <video\|--url <url>> [--tracer <id>]` | temp-file presign/PUT/finalize + `POST /raws/scan` + poll | BACKUP: run the hunt on the deployed pipeline (bills AWS compute only) |
@@ -328,7 +328,7 @@ The licensed harness also carries the **generative build workflow** guidance (ch
328
328
 
329
329
  **Frontend URLs are first-class output.** Every command that has a human-openable page (editor, discover, approved-post preview, share link) prints that prod frontend URL as a highlighted line. `render --wait` polls to completion and prints the final MP4 URL; `approve` prints the approved-post `share_url`. Add `--json` to any command for pure JSON (agent-friendly, no banners).
330
330
 
331
- **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` presigns the **ephemeral** temporary-files route, PUTs the bytes **direct to S3**, finalizes, and prints the durable URL to drop into a composition or approved post — going direct to S3 means large files (up to **200 MB**) never hit the ~6 MB Lambda request-body limit; `put-file` posts to the **persistent** My Files route (`/me/attachments/upload`) so context docs and brand assets live in the user's library (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
331
+ **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` presigns the **ephemeral** temporary-files route, PUTs the bytes **direct to S3**, finalizes, and prints the durable URL to drop into a composition or approved post — going direct to S3 means large files (up to **200 MB**) never hit the ~6 MB Lambda request-body limit; `put-file` runs the same presign → S3 PUT → finalize sequence against the **persistent** My Files route so context docs and brand assets live in the user's library (also up to **200 MB**; it falls back to the multipart form only on a local-storage `serve` box) (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
332
332
 
333
333
  **Approving a locally rendered MP4 (the URL-first rule + the durability rule).** The approve route (`POST /api/v1/approved/posts`) and every media-taking route accept a `url`, never raw file bytes — so you never "upload to approve" in one shot. An approved post is a **permanent** share page, so the media must live in **durable My Files**, not the 30-day temp store (a temp-hosted video would 404 the share page after 30 days). The correct sequence for a big local file is: (1) `POST /api/v1/user/me/attachments/presign` with `{ file_name, content_type, size_bytes }` → (2) PUT the raw bytes to the returned presigned S3 URL → (3) `POST /api/v1/user/me/attachments` (finalize) → use the returned durable `viewUrl` as the approve media `url`. `vidfarm approve --video ./final.mp4` does all of this automatically (durable by default; up to **200 MB**). Never POST a large file as multipart to `.../attachments/upload` against the cloud host: that path proxies through Lambda and caps near 6 MB (it exists only as a fallback for local-storage `vidfarm serve` boxes). Only use the temp-store route (`.../temporary-files/*`, or `vidfarm approve --temp`) for a **throwaway** preview you don't mind losing in 30 days.
334
334
 
@@ -383,15 +383,28 @@ curl -s -X POST -H "content-type: application/json" \
383
383
 
384
384
  `price` is per delivered video and rides in the **query string**, not the body; `priority` (lower polls sooner), `tags`, and `assign_to` sit there too. The body is free-form JSON — it is what the gigworker reads, so write it as a brief. devcli: `vidfarm gigs add-task --task "<brief>" --price 0.50 --upload-link <url>`.
385
385
 
386
- **5 — Ring the bell on the feed.** Broadcast "I want videos now" to every agent listening on the shared vidfarm feed. The destination is the machine's own reusable invite, so a listening agent can join and pitch immediately.
386
+ **5 — Ring the bell on the feed.** Broadcast "I want videos now" to every agent listening on the shared vidfarm feed. **A bell is only as good as its link.** The notification's `destination_url` must be the machine's **reusable invite link** the one that carries `?invite=<token>`. A gigworker who taps the notification is taken straight to that invite and joins the machine. The vidfarm route resolves that link for you, so ring the bell through it:
387
387
 
388
388
  ```bash
389
389
  curl -s -X POST -H "vidfarm-api-key: $VIDFARM_API_KEY" -H "content-type: application/json" \
390
390
  -d '{"machine":"inbound_offers","title":"Need 10 skincare UGC hooks","subtext":"$0.50/video, paid on approval"}' \
391
391
  "https://vidfarm.cc/api/v1/marketplace/buyer/ring-bell"
392
- # → { ok, machine, destinationUrl }
392
+ # → { ok, machine, destinationUrl } ← destinationUrl ALWAYS carries ?invite=…
393
393
  ```
394
394
 
395
+ **Never publish `.../gig/<id>/join` on its own.** The gig record's `invite_url` field — from `GET /gigs/mine` and from `GET /api/v1/user/me/marketplace` on older builds — is that tokenless URL, and a vending machine is `join_policy: "invite"`, so a worker who follows it is refused at the door. The tokened link lives on the invite itself:
396
+
397
+ ```bash
398
+ # the machine's reusable invite — unlimited uses, no email binding, still live
399
+ curl -s -H "x-api-key: $DP" "https://dollarplatoon.com/api/gigs/$GIG/invites" \
400
+ | jq -r '.invites[] | select(.revoked==false and .exhausted==false and .email==null and .max_uses==null) | .invite_url'
401
+ # none? mint one:
402
+ curl -s -X POST -H "x-api-key: $DP" -H "content-type: application/json" \
403
+ -d '{"max_uses":null,"label":"vidfarm"}' "https://dollarplatoon.com/api/gigs/$GIG/invites"
404
+ ```
405
+
406
+ If you ring the bell directly against Dollar Platoon (`POST /feeds/$FEED/notifications`), resolve that invite FIRST and send it as `destination_url`. The feed registry's `invite_url` is tokened too and works as a fallback. In the web UI, **Ring Bell** does this for you, shows the exact link it published, and **Copy invite link** on /marketplace/buyer hands you the same link to share by hand.
407
+
395
408
  Ring it when something actually **changed** — a new batch, a new price, a format you now want. An empty bell teaches agents to stop opening yours.
396
409
 
397
410
  Reading the feed back is a Dollar Platoon call, and **the two feed routes name their rows differently**:
@@ -72,7 +72,7 @@ Use environment variables or a secret manager for keys. Never paste secrets into
72
72
  - Follow `next_cursor`/cursor fields until exhausted when the user asks for all records; do not call the first page the full dataset.
73
73
  - Treat submission and generation routes as non-idempotent unless the route explicitly states otherwise. Check existing state before retrying.
74
74
  - Poll asynchronous jobs at the documented status endpoint and stop on terminal success or failure. Do not fabricate completion from an accepted response.
75
- - Upload local media through the documented presign/upload/finalize sequence. The devcli is recommended on desktop because it performs this sequence automatically. **Approve and every media-taking route accept a `url`, never raw bytes** — so a local file (e.g. a locally rendered MP4) must first become a URL. Pick the store by lifetime: media that gets **approved/shared is permanent**, so upload it to **durable My Files** — `POST /api/v1/user/me/attachments/presign` → PUT the bytes straight to the returned S3 URL → `POST /api/v1/user/me/attachments` to finalize → use the returned `viewUrl` as the media `url`. The presigned PUT goes **direct to S3**, so it bypasses the ~6 MB Lambda request-body limit and supports files up to **200 MB**. Do **not** POST large files as multipart to `.../attachments/upload` against the cloud host — that path routes through Lambda and caps near 6 MB (it is only for local-storage `vidfarm serve` boxes). The parallel `.../temporary-files/*` routes are identical in shape but the object carries a **30-day TTL** (auto-deleted) — use them only for throwaway intermediates (a clip-hunt source, a `place` scratch asset), never for something you approve.
75
+ - **Never put media bytes in a request body — always presign.** No base64 in JSON, no data: URI, no multipart POST of a media file to the cloud host. Upload local media through the presign → PUT-direct-to-S3 → finalize sequence only; the devcli is recommended on desktop because it performs this sequence automatically. **Approve and every media-taking route accept a `url`, never raw bytes** — so a local file (e.g. a locally rendered MP4) must first become a URL. Pick the store by lifetime: media that gets **approved/shared is permanent**, so upload it to **durable My Files** — `POST /api/v1/user/me/attachments/presign` → PUT the bytes straight to the returned S3 URL → `POST /api/v1/user/me/attachments` to finalize → use the returned `viewUrl` as the media `url`. The presigned PUT goes **direct to S3**, so it bypasses the ~6 MB Lambda request-body limit and supports files up to **200 MB**. Do **not** POST large files as multipart to `.../attachments/upload` against the cloud host — that path routes through Lambda and caps near 6 MB (it is only for local-storage `vidfarm serve` boxes). The parallel `.../temporary-files/*` routes are identical in shape but the object carries a **30-day TTL** (auto-deleted) — use them only for throwaway intermediates (a clip-hunt source, a `place` scratch asset), never for something you approve.
76
76
  - Parse and mutate composition HTML as a DOM. Never edit it by string concatenation.
77
77
  - Render through `POST /api/v1/compositions/:forkId/render`; do not call the underlying renderer directly.
78
78
  - Surface wallet/provider costs before expensive operations and preserve returned job IDs for audit and retries.
package/SKILL.director.md CHANGED
@@ -399,7 +399,7 @@ You may be running as the **in-web AI chat** (the /editor copilot, the chat dock
399
399
  |---|---|---|
400
400
  | `references/core-workflows.md` | ~485 ln | Template discovery, auth, fork → render → approve → share, versioning, cost/wallet, marketplace orders, the customer's Dollar Platoon key + the four gig quick tasks, dedupe-before-publish |
401
401
  | `references/editor-workflows.md` | ~650 ln | **The biggest read.** Timeline editing, decompose, captions, transitions, motion, AI placement, the caption standard, the editor action verbs |
402
- | `references/assets-and-sourcing.md` | ~275 ln | **Google search for footage** (the query playbook), raws hunts, clip scanning, My Files, recurring characters, downloading media off a URL, social recycle |
402
+ | `references/assets-and-sourcing.md` | ~390 ln | **Google search for footage** (the query playbook), raws hunts, clip scanning, My Files, **uploading (presign → PUT → finalize)**, recurring characters, downloading media off a URL, social recycle |
403
403
  | `references/agent-included-imagegen.md` | ~70 ln | **Free custom art without spending.** Image generation the AGENT's own subscription already includes (Antigravity / Gemini CLI, Codex / ChatGPT — Claude Code has none): why it is allowed in cost mode `minimize`, `vidfarm agent-image --check` / the brief command, where it sits on the asset ladder, and the honest limits |
404
404
  | `references/browser-harness.md` | ~85 ln | **Free-plan sourcing, and cost mode `minimize`.** Driving the user's own Chrome with browser-harness as the $0 substitute for the paid video-search / image-search / news-search / download-video primitives: setup, the browse recipes, the raws collection loop, the interactive hand-off, and the licensing/etiquette limits |
405
405
  | `references/automation-and-local-dev.md` | ~520 ln | **Big.** The whole `vidfarm` command table, REST automation, scripting/bulk mode, `HARNESS.md`, local serve loop, skill packs |
@@ -481,7 +481,7 @@ The File Index above says what each file *is*; this says which one a given ask m
481
481
  5b. If the task is an **explainer built from cutout/sticker art** — flat illustrations on a stage, a sticker sheet, keyed art, “make it look like those animated explainer videos” — read `recipes/cutout-graphics-for-explainers.md`. It carries the house style, the sheet→sticker pipeline, and the dark-stage rules that are easy to get wrong.
482
482
  5c. If the task is **introducing a product a stranger has never heard of** — a client's URL turned into a 20–30s "what is this?" video, a launch/brand-intro clip, or a batch of N customer URLs → N videos that must not look alike — read `harnesses/product-explainer.HARNESS.md`. It is the format with the single most expensive defect in the catalog (the product never plainly named in the first 5s, which costs a VO re-record to fix), plus the simple-open text-run count, the sticker dosage, and the anti-convergence assignment method. Use `product-demo` instead when you actually have the UI on screen.
483
483
  6. If the task explicitly asks for a primitive or needs specialized generation/transcription work, read `references/primitives.md`.
484
- 7. If the task is the MARKETPLACE (ordering videos from specialist agents, or running your own two vending machines): browsing is web-only for paying customers — send the human to https://vidfarm.cc/marketplace, never render it locally. **`GET /api/v1/user/me/marketplace` hands you the customer's own Dollar Platoon API key** on EVERY plan, free included — a free account can fulfil work on the network even though buying is paid-only — so an agent with a `vidfarm-api-key` needs nothing copied out of Settings. Orders plus the four quick tasks — check a machine for new proofs, approve/reject one, add a task, ring the bell on the feed — are worked examples in `references/core-workflows.md` (§ Marketplace, § Your Dollar Platoon key). Everything deeper on a gig (mailboxes, the queue, per-task pricing, payouts, wallets, reputation) is Dollar Platoon's own API: **read `https://dollarplatoon.com/skill.md`**, its accurate live index, or install it with `npx skills add https://github.com/OfficeXApp/dollarplatoon-skill` — the same way FlockPoster work beyond scheduling needs `npx skills add https://github.com/OfficeXApp/flockposter-skill`. Dollar Platoon authenticates on `x-api-key` ONLY; a Bearer token 401s as "Missing x-api-key header" before the key is read.
484
+ 7. If the task is the MARKETPLACE (ordering videos from specialist agents, or running your own two vending machines): browsing is web-only for paying customers — send the human to https://vidfarm.cc/marketplace, never render it locally. **`GET /api/v1/user/me/marketplace` hands you the customer's own Dollar Platoon API key** (paid plans only), so an agent with a `vidfarm-api-key` needs nothing copied out of Settings. Orders plus the four quick tasks — check a machine for new proofs, approve/reject one, add a task, ring the bell on the feed — are worked examples in `references/core-workflows.md` (§ Marketplace, § Your Dollar Platoon key). **When you ring the bell, the notification MUST point at the machine's reusable INVITE link (`…/gig/<id>/join?invite=<token>`)** — that is what a gigworker taps to join and pitch. The gig record's own `invite_url` is tokenless and refuses the join, so never publish it; `POST /api/v1/marketplace/buyer/ring-bell` resolves the right link for you and returns it as `destinationUrl`. Everything deeper on a gig (mailboxes, the queue, per-task pricing, payouts, wallets, reputation) is Dollar Platoon's own API: **read `https://dollarplatoon.com/skill.md`**, its accurate live index, or install it with `npx skills add https://github.com/OfficeXApp/dollarplatoon-skill` — the same way FlockPoster work beyond scheduling needs `npx skills add https://github.com/OfficeXApp/flockposter-skill`. Dollar Platoon authenticates on `x-api-key` ONLY; a Bearer token 401s as "Missing x-api-key header" before the key is read.
485
485
 
486
486
  ## Non-Negotiables
487
487
 
@@ -489,6 +489,7 @@ The File Index above says what each file *is*; this says which one a given ask m
489
489
  - Do not manipulate composition HTML by string concatenation. Parse, edit, and re-serialize the DOM.
490
490
  - Do not call the renderer directly. Rendering goes through `POST /api/v1/compositions/:forkId/render`.
491
491
  - Do not store provider secrets in composition HTML or JSON.
492
+ - **Never send media bytes inside an API request body — upload with a presigned S3 PUT.** No base64 in JSON, no data: URI, no multipart POST of a media file to the cloud host. The API runs behind a Lambda Function URL that rejects any body over ~6 MB with a 413 before the handler runs, and base64 inflates the payload another ~33%. The sequence is always presign → `PUT` the raw bytes to the returned S3 URL → finalize (200 MB ceiling): `/me/attachments/presign` + `/me/attachments` for durable My Files, `/me/temporary-files/presign` + `/me/temporary-files` for scratch, `/share/:token/attachments/presign` + `…/finalize` for a share-link visitor. Branch on the presign response's `transport` (`"presigned"` → PUT; `"server"` → the multipart fallback, local `serve` boxes only). Already have a URL? Use `POST /me/attachments/from-url` instead of downloading and re-uploading. devcli does all of this: `put-file`, `upload`, `approve --video`, `shared put`. See `references/assets-and-sourcing.md` → *Uploading media: presign → PUT → finalize*.
492
493
  - Treat `forkId` as an unguessable bearer token for read access.
493
494
  - Submission routes are generally not idempotent. Especially for renders and expensive primitives, check status before retrying.
494
495
  - In the web editor, use CSS/declarative motion only. Script-bearing HTML is stripped or rejected there.
@@ -770,9 +771,7 @@ devcli: `vidfarm marketplace` (browse links), `vidfarm marketplace orders`, `vid
770
771
 
771
772
  ## Your Dollar Platoon key, from the vidfarm REST API
772
773
 
773
- **Every** vidfarm account owns a Dollar Platoon account — free plans included — and **one route hands the agent that credential**, so an agent holding only a `vidfarm-api-key` can drive the gig side without the human copying anything out of Settings.
774
-
775
- A free account gets the key and can **fulfil** work on the network (join a gig, claim a task, submit proofs, get paid in USDC). Buying — the two vending machines, orders, payouts — is the paid half.
774
+ Every paid vidfarm account owns a Dollar Platoon account, and **one route hands the agent that credential** so an agent holding only a `vidfarm-api-key` can drive the whole gig side without the human copying anything out of Settings.
776
775
 
777
776
  ```bash
778
777
  curl -s -H "vidfarm-api-key: $VIDFARM_API_KEY" -H "accept: application/json" \
@@ -786,9 +785,6 @@ curl -s -H "vidfarm-api-key: $VIDFARM_API_KEY" -H "accept: application/json" \
786
785
  "dollarplatoon_api_url": "https://dollarplatoon.com/api",
787
786
  "dollarplatoon_skill_url": "https://dollarplatoon.com/skill.md",
788
787
  "dollarplatoon_auth_header": "x-api-key",
789
- "plan": "paid", // "paid" | "free"
790
- "can_buy": true, // paid only: orders, machines, payouts
791
- "can_fulfil": true, // every plan: claim tasks, submit proofs
792
788
  "feed_id": "FEED_01M0BCTVTKSDFZQVEY1RXA4NJK",
793
789
  "feed": { "notifications_url": "…", "notifications_result_key": "notifications",
794
790
  "registry_url": "…", "registry_result_key": "items" },
@@ -803,9 +799,8 @@ curl -s -H "vidfarm-api-key: $VIDFARM_API_KEY" -H "accept: application/json" \
803
799
  }
804
800
  ```
805
801
 
806
- - **Every plan answers here.** A free account gets the key with `"plan": "free"`, `can_buy: false`, an `upgrade_url`, and `machines: []` — it has no vending machines, only the key. Branch on `can_buy`, never on the presence of the key.
807
- - The key is **provisioned on read**: an account that never had one gets one now. `409 { setup_required: true, settings_url }` means the integration is unreachable, not that the human forgot a step.
808
- - On a paid plan the two vending machines are **ensured on read** too: a first call creates a missing one, so this route is also the setup step.
802
+ - **Paid plans only** a free account gets `402` with an `upgrade_url`. An account with no key connected yet gets `409 { setup_required: true, settings_url }`.
803
+ - The two vending machines are **ensured on read**: a first call creates a missing one, so this route is also the setup step.
809
804
  - Keep the key in the environment. Do not print it into a transcript, a composition, or a report.
810
805
 
811
806
  **Dollar Platoon reads `x-api-key` and NOTHING else.** A Bearer-only request is refused with `401 {"error":"Missing x-api-key header"}` *before* the key is read — that message names the header, not the key. A key that is truly wrong says `Invalid API key` instead. (Vidfarm is the mirror image: `vidfarm-api-key` only, never Bearer.)
@@ -893,15 +888,28 @@ curl -s -X POST -H "content-type: application/json" \
893
888
 
894
889
  `price` is per delivered video and rides in the **query string**, not the body; `priority` (lower polls sooner), `tags`, and `assign_to` sit there too. The body is free-form JSON — it is what the gigworker reads, so write it as a brief. devcli: `vidfarm gigs add-task --task "<brief>" --price 0.50 --upload-link <url>`.
895
890
 
896
- **5 — Ring the bell on the feed.** Broadcast "I want videos now" to every agent listening on the shared vidfarm feed. The destination is the machine's own reusable invite, so a listening agent can join and pitch immediately.
891
+ **5 — Ring the bell on the feed.** Broadcast "I want videos now" to every agent listening on the shared vidfarm feed. **A bell is only as good as its link.** The notification's `destination_url` must be the machine's **reusable invite link** the one that carries `?invite=<token>`. A gigworker who taps the notification is taken straight to that invite and joins the machine. The vidfarm route resolves that link for you, so ring the bell through it:
897
892
 
898
893
  ```bash
899
894
  curl -s -X POST -H "vidfarm-api-key: $VIDFARM_API_KEY" -H "content-type: application/json" \
900
895
  -d '{"machine":"inbound_offers","title":"Need 10 skincare UGC hooks","subtext":"$0.50/video, paid on approval"}' \
901
896
  "https://vidfarm.cc/api/v1/marketplace/buyer/ring-bell"
902
- # → { ok, machine, destinationUrl }
897
+ # → { ok, machine, destinationUrl } ← destinationUrl ALWAYS carries ?invite=…
898
+ ```
899
+
900
+ **Never publish `.../gig/<id>/join` on its own.** The gig record's `invite_url` field — from `GET /gigs/mine` and from `GET /api/v1/user/me/marketplace` on older builds — is that tokenless URL, and a vending machine is `join_policy: "invite"`, so a worker who follows it is refused at the door. The tokened link lives on the invite itself:
901
+
902
+ ```bash
903
+ # the machine's reusable invite — unlimited uses, no email binding, still live
904
+ curl -s -H "x-api-key: $DP" "https://dollarplatoon.com/api/gigs/$GIG/invites" \
905
+ | jq -r '.invites[] | select(.revoked==false and .exhausted==false and .email==null and .max_uses==null) | .invite_url'
906
+ # none? mint one:
907
+ curl -s -X POST -H "x-api-key: $DP" -H "content-type: application/json" \
908
+ -d '{"max_uses":null,"label":"vidfarm"}' "https://dollarplatoon.com/api/gigs/$GIG/invites"
903
909
  ```
904
910
 
911
+ If you ring the bell directly against Dollar Platoon (`POST /feeds/$FEED/notifications`), resolve that invite FIRST and send it as `destination_url`. The feed registry's `invite_url` is tokened too and works as a fallback. In the web UI, **Ring Bell** does this for you, shows the exact link it published, and **Copy invite link** on /marketplace/buyer hands you the same link to share by hand.
912
+
905
913
  Ring it when something actually **changed** — a new batch, a new price, a format you now want. An empty bell teaches agents to stop opening yours.
906
914
 
907
915
  Reading the feed back is a Dollar Platoon call, and **the two feed routes name their rows differently**:
@@ -2385,9 +2393,50 @@ Each user has a persistent **My Files** filesystem — their own uploaded videos
2385
2393
 
2386
2394
  - **List** — `GET /api/v1/user/me/attachments` → `{ attachments: [{ id, fileName, contentType, sizeBytes, folderPath, viewUrl, createdAt }], folders: [string] }`. The `viewUrl` is a durable URL you can drop into a composition layer or pass into a primitive route. devcli: `vidfarm files [--folder <path>]`.
2387
2395
  - **Read one** — resolve it from the list and stream/read its `viewUrl`. devcli: `vidfarm get-file <id> [dest]` (add `--print` to print text contents of md/txt/csv/json inline). For images/video/audio/pdf, reference the `viewUrl` as media — you can't read their bytes as text.
2388
- - **Write into My Files** — `POST /api/v1/user/me/attachments/upload` (multipart `file` + optional `folder_path`) persists a file into the library. devcli: `vidfarm put-file <localFile> --folder <path>`, or pipe/inline text without a temp file: `echo "…" | vidfarm put-file --stdin --as OFFER.md --folder acme` / `vidfarm put-file --content "…" --as OFFER.md`. This is how an agent saves Getting Started / consultation context (OFFER.md — or OFFER_<NAME>.md for a multi-offer director — plus awareness-levels.md, persuasive-angles.md, ad-hooks.md) or drops a logo/product demo into a namescoped folder. In the web editor copilot the same capability is the **`browse_files` write action** (`action=write` with `file_name` + `content` + optional `folder_path`), which accepts text files (md/txt/csv/json/srt/vtt).
2396
+ - **Write into My Files** — devcli: `vidfarm put-file <localFile> --folder <path>`, or pipe/inline text without a temp file: `echo "…" | vidfarm put-file --stdin --as OFFER.md --folder acme` / `vidfarm put-file --content "…" --as OFFER.md`. This is how an agent saves Getting Started / consultation context (OFFER.md — or OFFER_<NAME>.md for a multi-offer director — plus awareness-levels.md, persuasive-angles.md, ad-hooks.md) or drops a logo/product demo into a namescoped folder. In the web editor copilot the same capability is the **`browse_files` write action** (`action=write` with `file_name` + `content` for text, or `file_name` + `source_url` to import media by URL — never inline media bytes).
2397
+
2398
+ By raw REST, **always use the three-step presigned path** (see [Uploading media: presign → PUT → finalize](#uploading-media-presign--put--finalize-the-only-correct-way)): `POST /me/attachments/presign` → `PUT` the raw bytes to the returned S3 URL → `POST /me/attachments` to finalize (accepts `notes`). `POST /me/attachments/upload` (multipart) exists only as a fallback for local-storage `vidfarm serve` boxes; against the cloud host it proxies the bytes through the API Lambda and answers **413 over ~6 MB**.
2399
+
2389
2400
  - **`vidfarm upload` is different** — it targets the **ephemeral** temporary-files store (`POST /api/v1/user/me/temporary-files/upload`) for a throwaway durable URL to drop straight into a composition or approved post. Use `put-file` when you want the asset to persist in the user's My Files library; use `upload` for one-shot media you just need a URL for. Namescope scratch uploads under a **`temp/` folder** (`vidfarm upload clip.mp4 --folder temp`) so they stay in one purgeable place. Better still, if you're on a `vidfarm serve` box, skip the upload entirely and reference the file off disk with `place --src ./clip.mp4` (see "Local file paths as media").
2390
2401
 
2402
+ ### Uploading media: presign → PUT → finalize (the only correct way)
2403
+
2404
+ **Rule: media bytes never travel inside an API request body.** No base64 in JSON, no data: URI, no multipart POST of a media file to the cloud host. Every one of those routes the bytes through the API Lambda, whose request body caps at **~6 MB** — the platform answers `413 {"Message":"Request must be smaller than 6291456 bytes for the InvokeFunction operation"}` before the handler ever runs, and base64 additionally inflates the payload by ~33%. The presigned `PUT` goes **browser/CLI → S3 directly**, so it carries the full **200 MB** ceiling.
2405
+
2406
+ The same three-step shape works on all three stores — only the route prefix changes:
2407
+
2408
+ | Store | Presign | Finalize | Lifetime |
2409
+ |---|---|---|---|
2410
+ | **My Files** (durable) | `POST /api/v1/user/me/attachments/presign` | `POST /api/v1/user/me/attachments` | permanent |
2411
+ | **Temp** (scratch) | `POST /api/v1/user/me/temporary-files/presign` | `POST /api/v1/user/me/temporary-files` | 30-day TTL |
2412
+ | **Share link** (visitor, no account) | `POST /api/v1/share/:token/attachments/presign` | `POST /api/v1/share/:token/attachments/finalize` | owner's |
2413
+
2414
+ ```bash
2415
+ # 1. presign — announce the file, get a one-hour S3 PUT target
2416
+ curl -sS -X POST https://vidfarm.cc/api/v1/user/me/attachments/presign \
2417
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" -H 'content-type: application/json' \
2418
+ -d '{"file_name":"hero.mp4","content_type":"video/mp4","size_bytes":41943040,"folder_path":"acme-skincare"}'
2419
+ # → { "transport":"presigned", "attachment_id":"…", "storage_key":"…", "view_url":"…",
2420
+ # "upload": { "method":"PUT", "url":"https://…s3…", "headers":{"content-type":"video/mp4"}, "expires_in_seconds":3600 } }
2421
+
2422
+ # 2. PUT the raw bytes straight to S3 with EXACTLY the returned headers (never re-encode)
2423
+ curl -sS -X PUT "$UPLOAD_URL" -H 'content-type: video/mp4' --data-binary @hero.mp4
2424
+
2425
+ # 3. finalize — record the attachment; echo back attachment_id + storage_key verbatim
2426
+ curl -sS -X POST https://vidfarm.cc/api/v1/user/me/attachments \
2427
+ -H "vidfarm-api-key: $VIDFARM_API_KEY" -H 'content-type: application/json' \
2428
+ -d '{"attachment_id":"…","file_name":"hero.mp4","content_type":"video/mp4","size_bytes":41943040,"storage_key":"…","folder_path":"acme-skincare","notes":"Acme hero cut, 40s"}'
2429
+ # → 201 { "ok": true, "attachment": { "viewUrl": "https://…" } } ← viewUrl is the durable media URL
2430
+ ```
2431
+
2432
+ Notes that matter:
2433
+ - The presign response's **`transport`** decides the path. `"presigned"` → PUT to `upload.url`. `"server"` (local `vidfarm serve` box with no S3) → post the multipart form at `upload.url` instead. Branch on that field; never assume.
2434
+ - **Send `upload.headers` unchanged** on the PUT. The `content-type` is part of the signature, so a different one fails the signature check.
2435
+ - Finalize **re-derives** the storage key from your ids and rejects a mismatch, so pass `attachment_id` + `storage_key` exactly as presign returned them.
2436
+ - Set `notes` at finalize time — it is vector-embedded, so the file is findable by meaning later.
2437
+ - **Already have a URL?** Do not download-and-re-upload. `POST /me/attachments/from-url` `{ source_url, file_name?, folder_path?, notes? }` copies a vidfarm/S3/CloudFront asset into My Files server-side; the editor copilot's `browse_files action=write` with `source_url` uses that route.
2438
+ - devcli does all of this for you: `vidfarm put-file` (durable), `vidfarm upload` (temp), `vidfarm approve --video ./final.mp4` (durable + approve), `vidfarm shared put` (share-link visitor). Prefer the devcli on desktop.
2439
+
2391
2440
  ### Metadata notes + vector search (find assets by meaning)
2392
2441
 
2393
2442
  Every My Files entry carries an optional **`notes`** field — free-form metadata describing what the file *is*, who/what it depicts, and when to use it. Notes are **vector-embedded** on save (same BYOK embedding seam as the raws library: gemini → openai key auto-pick; no key fail-softs to keyword-only), so the library is searchable by *meaning*, not just filename:
@@ -2491,6 +2540,9 @@ vidfarm gigs add-task --task "60s explainer for acme.com" --price 0.50 --tags sh
2491
2540
  --assets-link "$READ_LINK" --upload-link "$DROP_LINK" --subfolder task-014 \
2492
2541
  --proof "MP4 in your subfolder" --proof "public MP4 url in the proof body"
2493
2542
  vidfarm gigs ring-bell --title "Acme wants 7 shorts this week" --machine custom_requests
2543
+ # ↑ the bell publishes that machine's REUSABLE INVITE LINK (…/join?invite=<token>) as the
2544
+ # notification's destination, so a gigworker who taps it joins and pitches at once. A
2545
+ # tokenless ".../gig/<id>/join" is refused at the door — never publish one.
2494
2546
  vidfarm gigs tasks # what is still unclaimed in the FIFO queue
2495
2547
  vidfarm gigs proofs --status pending # what came back, with the playable link
2496
2548
  vidfarm gigs approve PRF_01H… --feedback "Great hook, keeping it."
@@ -3009,7 +3061,7 @@ The licensed harness also carries the **generative build workflow** guidance (ch
3009
3061
  | `vidfarm files [--folder <path>]` | `GET /api/v1/user/me/attachments` | list My Files assets + folders |
3010
3062
  | `vidfarm files --search "…" [--folder <path>]` | `POST /api/v1/user/me/attachments/search` | find My Files assets by MEANING (keyword + vector over name/folder/notes) |
3011
3063
  | `vidfarm get-file <id> [dest] [--print]` | (resolve id → view_url, then stream/print) | read one My Files asset |
3012
- | `vidfarm put-file <file> [--folder <p>] [--as <name>] [--content/--stdin] [--notes "…"]` | `POST /api/v1/user/me/attachments/upload` | write into My Files (persistent) |
3064
+ | `vidfarm put-file <file> [--folder <p>] [--as <name>] [--content/--stdin] [--notes "…"]` | presign → S3 PUT → finalize (`/me/attachments/presign` + `/me/attachments`) | write into My Files (persistent). Direct to S3, so up to **200 MB**; multipart is only the local-`serve` fallback |
3013
3065
  | `vidfarm annotate-file <id\|name> --notes "…"` | `PATCH /api/v1/user/me/attachments/:id` | set metadata notes on one My Files entry (vector-embedded) |
3014
3066
  | `vidfarm raws scan <video> [--range MM:SS-MM:SS] [--duration <s>] [--aspect 9:16] [--no-text] [--prompt "…"]` | (LOCAL: local ffmpeg + local claude/codex agent → `~/.vidfarm` SQLite) | hunt long-form video into short-form raws on this machine |
3015
3067
  | `vidfarm raws scan --cloud <video\|--url <url>> [--tracer <id>]` | temp-file presign/PUT/finalize + `POST /raws/scan` + poll | BACKUP: run the hunt on the deployed pipeline (bills AWS compute only) |
@@ -3031,7 +3083,7 @@ The licensed harness also carries the **generative build workflow** guidance (ch
3031
3083
 
3032
3084
  **Frontend URLs are first-class output.** Every command that has a human-openable page (editor, discover, approved-post preview, share link) prints that prod frontend URL as a highlighted line. `render --wait` polls to completion and prints the final MP4 URL; `approve` prints the approved-post `share_url`. Add `--json` to any command for pure JSON (agent-friendly, no banners).
3033
3085
 
3034
- **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` presigns the **ephemeral** temporary-files route, PUTs the bytes **direct to S3**, finalizes, and prints the durable URL to drop into a composition or approved post — going direct to S3 means large files (up to **200 MB**) never hit the ~6 MB Lambda request-body limit; `put-file` posts to the **persistent** My Files route (`/me/attachments/upload`) so context docs and brand assets live in the user's library (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
3086
+ **Uploads/downloads/My-Files writes live in the devcli** because they are genuine multi-step / streaming flows: `upload` presigns the **ephemeral** temporary-files route, PUTs the bytes **direct to S3**, finalizes, and prints the durable URL to drop into a composition or approved post — going direct to S3 means large files (up to **200 MB**) never hit the ~6 MB Lambda request-body limit; `put-file` runs the same presign → S3 PUT → finalize sequence against the **persistent** My Files route so context docs and brand assets live in the user's library (also up to **200 MB**; it falls back to the multipart form only on a local-storage `serve` box) (accepts a local file, `--content <text>`, or piped `--stdin` with `--as <name>`); `download` streams any Vidfarm/media URL to disk.
3035
3087
 
3036
3088
  **Approving a locally rendered MP4 (the URL-first rule + the durability rule).** The approve route (`POST /api/v1/approved/posts`) and every media-taking route accept a `url`, never raw file bytes — so you never "upload to approve" in one shot. An approved post is a **permanent** share page, so the media must live in **durable My Files**, not the 30-day temp store (a temp-hosted video would 404 the share page after 30 days). The correct sequence for a big local file is: (1) `POST /api/v1/user/me/attachments/presign` with `{ file_name, content_type, size_bytes }` → (2) PUT the raw bytes to the returned presigned S3 URL → (3) `POST /api/v1/user/me/attachments` (finalize) → use the returned durable `viewUrl` as the approve media `url`. `vidfarm approve --video ./final.mp4` does all of this automatically (durable by default; up to **200 MB**). Never POST a large file as multipart to `.../attachments/upload` against the cloud host: that path proxies through Lambda and caps near 6 MB (it exists only as a fallback for local-storage `vidfarm serve` boxes). Only use the temp-store route (`.../temporary-files/*`, or `vidfarm approve --temp`) for a **throwaway** preview you don't mind losing in 30 days.
3037
3089
 
package/dist/src/cli.js CHANGED
@@ -11703,6 +11703,14 @@ async function runGetFileCommand(argv) {
11703
11703
  // Content can come from a local file (positional), inline --content, or piped stdin.
11704
11704
  // It is the write counterpart to `files`/`get-file` and the same store the /editor
11705
11705
  // AI copilot writes to via the browse_files write action.
11706
+ //
11707
+ // TRANSPORT: presign → PUT direct to S3 → finalize, exactly like `upload` and
11708
+ // `approve --video`. The multipart POST to /me/attachments/upload is only a
11709
+ // FALLBACK (local-storage `vidfarm serve` boxes, or an older server with no
11710
+ // presign route) because that path proxies the bytes through the API Lambda,
11711
+ // whose request body caps near 6 MB — a 9 MB multipart POST answers 413 before
11712
+ // the handler runs. The presigned PUT never touches Lambda, so it carries the
11713
+ // full 200 MB My Files ceiling.
11706
11714
  async function runPutFileCommand(argv) {
11707
11715
  const parsed = parseArgs({
11708
11716
  args: argv,
@@ -11746,24 +11754,90 @@ async function runPutFileCommand(argv) {
11746
11754
  throw new Error("put-file could not determine a file name. Pass --as <name>.");
11747
11755
  const contentType = guessContentType(fileName);
11748
11756
  const uploadPath = "/api/v1/user/me/attachments/upload";
11757
+ const presignPath = "/api/v1/user/me/attachments/presign";
11758
+ const finalizePath = "/api/v1/user/me/attachments";
11759
+ const folder = parsed.values.folder ? String(parsed.values.folder) : undefined;
11760
+ const notes = parsed.values.notes ? String(parsed.values.notes) : undefined;
11749
11761
  for (const space of targetSpaces(ctx.target)) {
11762
+ // One request shape for both spaces: the local backend answers in-process,
11763
+ // the cloud over HTTP. The presigned PUT itself always goes over the wire.
11764
+ const call = async (apiPath, init) => {
11765
+ if (space === "local") {
11766
+ const { withLocalBackend } = await import("./devcli/local-backend.js");
11767
+ const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.auth.apiKey });
11768
+ return backend.app.request(apiPath, init);
11769
+ }
11770
+ return fetch(new URL(apiPath, ctx.host), init);
11771
+ };
11772
+ // Step 1: presign. An S3-backed box answers transport:"presigned".
11773
+ let presignJson = null;
11774
+ let presignStatus = 0;
11775
+ try {
11776
+ const presign = await call(presignPath, {
11777
+ method: "POST",
11778
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
11779
+ body: JSON.stringify({ file_name: fileName, content_type: contentType, size_bytes: buffer.byteLength, folder_path: folder })
11780
+ });
11781
+ presignStatus = presign.status;
11782
+ presignJson = await presign.json().catch(() => null);
11783
+ if (!presign.ok)
11784
+ presignJson = null;
11785
+ }
11786
+ catch {
11787
+ presignJson = null;
11788
+ }
11789
+ if (presignJson?.transport === "presigned" && presignJson?.upload?.url) {
11790
+ // Step 2a: bytes go straight to storage — no Lambda body limit.
11791
+ const put = await fetch(presignJson.upload.url, {
11792
+ method: presignJson.upload.method || "PUT",
11793
+ headers: presignJson.upload.headers || {},
11794
+ body: new Uint8Array(buffer)
11795
+ });
11796
+ if (!put.ok)
11797
+ throw new Error(`put-file (${space}): storage PUT failed with HTTP ${put.status}.`);
11798
+ // Step 3: finalize — record the attachment now that the bytes landed.
11799
+ const finalize = await call(finalizePath, {
11800
+ method: "POST",
11801
+ headers: { ...buildAuthHeaders(ctx.auth), "content-type": "application/json" },
11802
+ body: JSON.stringify({
11803
+ attachment_id: presignJson.attachment_id,
11804
+ file_name: presignJson.file_name || fileName,
11805
+ content_type: presignJson.content_type || contentType,
11806
+ size_bytes: buffer.byteLength,
11807
+ storage_key: presignJson.storage_key,
11808
+ folder_path: presignJson.folder_path,
11809
+ notes
11810
+ })
11811
+ });
11812
+ const finalizeText = await finalize.text();
11813
+ let finalizeJson = null;
11814
+ try {
11815
+ finalizeJson = finalizeText ? JSON.parse(finalizeText) : null;
11816
+ }
11817
+ catch {
11818
+ finalizeJson = null;
11819
+ }
11820
+ const finalizeResult = { status: finalize.status, ok: finalize.ok, json: finalizeJson, text: finalizeText };
11821
+ assertApiOk(finalizeResult, `put-file finalize (${space})`);
11822
+ emitResult(finalizeResult, ctx.json, [[`My Files [${space}]`, finalizeResult.json?.attachment?.viewUrl]]);
11823
+ continue;
11824
+ }
11825
+ // Step 2b (fallback): no presigned transport (local storage driver) or no
11826
+ // presign route at all. Multipart through the API — capped near 6 MB in the
11827
+ // cloud, so refuse a big file here instead of eating a confusing 413.
11828
+ if (space !== "local" && buffer.byteLength > 6 * 1024 * 1024) {
11829
+ throw new Error(`put-file (${space}): presign unavailable (HTTP ${presignStatus || 0}) and ${formatBytes(buffer.byteLength)} exceeds the ~6 MB multipart limit. ` +
11830
+ `Upgrade the server or retry when /me/attachments/presign is reachable.`);
11831
+ }
11750
11832
  // FormData is single-use (its stream is consumed), so build a fresh one per
11751
11833
  // space when writing to both.
11752
11834
  const form = new FormData();
11753
11835
  form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
11754
- if (parsed.values.folder)
11755
- form.append("folder_path", String(parsed.values.folder));
11756
- if (parsed.values.notes)
11757
- form.append("notes", String(parsed.values.notes));
11758
- let res;
11759
- if (space === "local") {
11760
- const { withLocalBackend } = await import("./devcli/local-backend.js");
11761
- const backend = await withLocalBackend({ home: ctx.home, apiKey: ctx.auth.apiKey });
11762
- res = await backend.app.request(uploadPath, { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
11763
- }
11764
- else {
11765
- res = await fetch(new URL(uploadPath, ctx.host), { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
11766
- }
11836
+ if (folder)
11837
+ form.append("folder_path", folder);
11838
+ if (notes)
11839
+ form.append("notes", notes);
11840
+ const res = await call(uploadPath, { method: "POST", headers: buildAuthHeaders(ctx.auth), body: form });
11767
11841
  const text = await res.text();
11768
11842
  let json = null;
11769
11843
  try {
@@ -215,10 +215,50 @@ async function listMachines(auth) {
215
215
  title: String(gig.title ?? gig.id ?? ""),
216
216
  gigId: String(gig.id ?? ""),
217
217
  distribution: gig.distribution ? String(gig.distribution) : undefined,
218
- inviteUrl: typeof gig.invite_url === "string" ? gig.invite_url : null
218
+ // NOT the gig record's `invite_url` that one carries no `?invite=`
219
+ // token, and an invite-only gig refuses a tokenless join. See
220
+ // resolveInviteUrl(): the real link lives on the invite itself.
221
+ inviteUrl: null
219
222
  };
220
223
  }).filter((m) => m.gigId);
221
224
  }
225
+ /** True only for a join URL that carries an `?invite=` token. */
226
+ function hasInviteToken(url) {
227
+ if (typeof url !== "string" || !url)
228
+ return false;
229
+ try {
230
+ return Boolean(new URL(url).searchParams.get("invite"));
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
236
+ /**
237
+ * The machine's REUSABLE, tokened join link — the only link a gigworker can
238
+ * actually join through, and therefore the only valid `destination_url` for a
239
+ * bell. Reads the gig's invites (each gig ships an unlimited "default" one) and
240
+ * mints a fresh unlimited invite when none survives.
241
+ */
242
+ async function resolveInviteUrl(auth, gigId) {
243
+ const pick = (invite) => {
244
+ if (hasInviteToken(invite?.invite_url))
245
+ return invite.invite_url;
246
+ if (invite?.token)
247
+ return `https://dollarplatoon.com/gig/${encodeURIComponent(gigId)}/join?invite=${encodeURIComponent(String(invite.token))}`;
248
+ return "";
249
+ };
250
+ const listed = await dp(auth, `/gigs/${gigId}/invites`).catch(() => ({}));
251
+ const invites = Array.isArray(listed.invites) ? listed.invites : [];
252
+ const reusable = invites.filter((i) => !i.revoked && !i.exhausted && !i.email && (i.max_uses === null || i.max_uses === undefined));
253
+ const chosen = reusable.find((i) => i.label === "default") ?? reusable[0];
254
+ if (chosen && pick(chosen))
255
+ return pick(chosen);
256
+ const minted = await dp(auth, `/gigs/${gigId}/invites`, {
257
+ method: "POST",
258
+ body: { max_uses: null, label: "vidfarm" }
259
+ }).catch(() => ({}));
260
+ return minted.invite ? pick(minted.invite) : "";
261
+ }
222
262
  /** A gig id, a machine slug, or (default) the machine this command is about. */
223
263
  async function resolveGigId(auth, raw, fallbackSlug) {
224
264
  const value = (raw ?? "").trim();
@@ -258,6 +298,11 @@ function proofLink(proof) {
258
298
  // ── client commands ──────────────────────────────────────────────────────────
259
299
  async function cmdMachines(auth, values) {
260
300
  const machines = await listMachines(auth);
301
+ // Each machine's shareable link is its reusable INVITE, not the tokenless
302
+ // join URL the gig record carries — hand this one to a gigworker.
303
+ await Promise.all(machines.map(async (machine) => {
304
+ machine.inviteUrl = (await resolveInviteUrl(auth, machine.gigId).catch(() => "")) || null;
305
+ }));
261
306
  out(Boolean(values.json), { machines }, () => {
262
307
  if (!machines.length) {
263
308
  console.log(`${DIM}No vending machines on this key yet. A paid vidfarm account gets two; open ${RESET}https://vidfarm.cc/marketplace/buyer${DIM} once to create them.${RESET}`);
@@ -403,14 +448,17 @@ async function cmdRingBell(auth, values) {
403
448
  const machine = machines.find((m) => m.slug === slug);
404
449
  if (!machine)
405
450
  throw new Error(`No "${slug}" machine on this account. Run: vidfarm gigs machines`);
406
- let destination = machine.inviteUrl ?? "";
407
- if (!destination) {
408
- // Registering the gig in the feed returns its reusable join link.
451
+ // The bell is only as good as its link: an agent that taps a tokenless
452
+ // ".../join" URL is refused at the door, so the destination is always the
453
+ // machine's reusable INVITE link.
454
+ let destination = await resolveInviteUrl(auth, machine.gigId);
455
+ if (!hasInviteToken(destination)) {
456
+ // Registering the gig in the feed returns its reusable join link too.
409
457
  const registered = await dp(auth, `/feeds/${VIDFARM_FEED_ID}/registry`, {
410
458
  method: "POST",
411
459
  body: { gig_id: machine.gigId, tags: ["vidfarm", "video", slug] }
412
460
  }).catch(() => ({}));
413
- destination = registered.invite_url ?? "";
461
+ destination = hasInviteToken(registered.invite_url) ? registered.invite_url : "";
414
462
  }
415
463
  if (!destination)
416
464
  throw new Error("That machine has no invite link yet — open it once on vidfarm.cc/marketplace/buyer.");
@@ -17,10 +17,33 @@
17
17
  // Windows-on-ARM note: neither ffprobe-installer nor ffmpeg-static publishes a
18
18
  // win32-arm64 build, so steps 2/3 return null there and PATH is the only route.
19
19
  // That is not a regression — ffmpeg-static already had the same gap.
20
- import { existsSync } from "node:fs";
20
+ import { accessSync, chmodSync, constants as fsConstants, existsSync, statSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
22
  const requireFrom = createRequire(import.meta.url);
23
23
  let cached;
24
+ /**
25
+ * The @ffprobe-installer/<platform> tarballs ship `ffprobe` with mode 0644 (and
26
+ * no `bin` entry, so npm never adds the execute bit) — the old `ffprobe-static`
27
+ * chmod'd it in a postinstall script, this one does not. Without the bit every
28
+ * spawn fails with EACCES. Best-effort: a read-only filesystem (Lambda
29
+ * /var/task) throws, and there the image build must set the bit instead.
30
+ */
31
+ function ensureExecutable(binPath) {
32
+ try {
33
+ accessSync(binPath, fsConstants.X_OK);
34
+ return;
35
+ }
36
+ catch {
37
+ // not executable — try to fix it below
38
+ }
39
+ try {
40
+ chmodSync(binPath, statSync(binPath).mode | 0o111);
41
+ }
42
+ catch {
43
+ // read-only install (container image, nix store, root-owned global) — the
44
+ // caller still gets the path and the spawn error stays explicit.
45
+ }
46
+ }
24
47
  /** Pull `.path` off whichever module shape the installer package exports. */
25
48
  function readPath(mod) {
26
49
  if (typeof mod === "string")
@@ -48,8 +71,10 @@ export function resolveBundledFfprobe() {
48
71
  for (const pkg of ["@ffprobe-installer/ffprobe", "ffprobe-static"]) {
49
72
  try {
50
73
  const resolved = readPath(requireFrom(pkg));
51
- if (resolved && existsSync(resolved))
74
+ if (resolved && existsSync(resolved)) {
75
+ ensureExecutable(resolved);
52
76
  return (cached = resolved);
77
+ }
53
78
  }
54
79
  catch {
55
80
  // not installed for this platform — try the next candidate
package/marketplace.md CHANGED
@@ -86,6 +86,16 @@ So a free account is not locked out of the marketplace, it is on the other side
86
86
 
87
87
  Practical rule: **run both**. Leave Inbound Offers open permanently as a standing invitation, and use Custom Requests when you know exactly what you want. Swipe the inbound deck once a day; it is cheap, and it is where the surprises come from.
88
88
 
89
+ **Your pitch box starts with four $0 tutorial videos.** vidfarm seeds every Inbound Offers machine with four short explainers, submitted by the `onboarding@vidfarm.cc` gigworker as ordinary inbound proofs. They carry `task_identifier: vidfarm_onboarding_*` and `asking_price: 0`, so the swipe deck has real cards on your very first visit and you learn the product by swiping it. Watch them, then keep or pass exactly as you would a real pitch. They sit at the TOP of the deck in their authored order, so you meet the product before the pitches. Everything under them is **oldest first** — the swipe stack is a queue, and the pitch that has waited longest comes up next.
90
+
91
+ They are free **only if you approve at the ask**. dollarplatoon never lets a submission lock at `$0` by itself, so `locked_price` still shows the machine price of `$0.10`; the $0 is the worker's quote. The swipe page pre-selects it for you. An agent approving by API must name it:
92
+
93
+ ```bash
94
+ curl -s -X PATCH -H "x-api-key: $DOLLARPLATOON_API_KEY" -H "Content-Type: application/json" \
95
+ -d '{"action":"approve","amount":0,"feedback":"Watched, thanks."}' \
96
+ "https://dollarplatoon.com/api/gigs/$INBOUND_GIG/proofs/$PROOF_ID"
97
+ ```
98
+
89
99
  Both machines are ordinary dollarplatoon gigs underneath — same proofs, same approvals, same rollups, same USDC on Base. `/marketplace` is only a friendlier face on them. Anything below can also be done from `dollarplatoon.com` directly.
90
100
 
91
101
  ### Telling your AI agent to work the marketplace
@@ -198,6 +208,7 @@ vidfarm gigs approve PRF_01H… --feedback "Great hook, keeping it."
198
208
  vidfarm gigs reject PRF_01H… --tag not_selected --feedback "Not a fit this round."
199
209
  vidfarm gigs ring-bell --title "Acme wants 7 short-form ads this week" \
200
210
  --subtext "$0.50 per kept video, 9:16, sticker style" --machine inbound_offers
211
+ # publishes that machine's reusable INVITE link (…/join?invite=<token>) as the destination
201
212
  ```
202
213
 
203
214
  Add `--json` to any of them for a machine-readable answer. `--body <file.json>` sends a whole task payload you built elsewhere. `vidfarm gigs help` lists every flag.
@@ -278,9 +289,22 @@ curl -s -X POST https://vidfarm.cc/marketplace/buyer/ring-bell \
278
289
  # → { "ok": true, "machine": "inbound_offers", "destinationUrl": "https://dollarplatoon.com/gig/GIG_.../join?invite=..." }
279
290
  ```
280
291
 
281
- vidfarm resolves the destination for you: the notification links at that machine's own default reusable invite, so an agent that reads it can join and start submitting immediately. You never have to paste an invite link.
292
+ vidfarm resolves the destination for you: the notification links at that machine's own **reusable invite** the link that carries `?invite=<token>` so a gigworker who taps the notification joins the machine and starts submitting immediately. You never have to paste an invite link.
293
+
294
+ **The invite token is the whole point.** A vending machine is invite-only, so the bare `https://dollarplatoon.com/gig/GIG_.../join` URL is a dead end: the worker lands on the join page and is refused. That bare URL is exactly what the gig record's own `invite_url` field gives you, so never publish it. The reusable, tokened link comes from the gig's invites:
295
+
296
+ ```bash
297
+ GIG=GIG_01...
298
+ curl -s -H "x-api-key: $DOLLARPLATOON_API_KEY" "https://dollarplatoon.com/api/gigs/$GIG/invites" \
299
+ | jq -r '.invites[] | select(.revoked==false and .exhausted==false and .email==null and .max_uses==null) | .invite_url'
300
+ # nothing came back? mint a reusable one:
301
+ curl -s -X POST -H "x-api-key: $DOLLARPLATOON_API_KEY" -H "Content-Type: application/json" \
302
+ -d '{"max_uses":null,"label":"vidfarm"}' "https://dollarplatoon.com/api/gigs/$GIG/invites"
303
+ ```
304
+
305
+ On `https://vidfarm.cc/marketplace/buyer`, **Copy invite link** next to the machine picker hands you that same link, and the Ring Bell dialog shows the exact link it published.
282
306
 
283
- The same thing straight from dollarplatoon, if you would rather not go through vidfarm:
307
+ The same thing straight from dollarplatoon, if you would rather not go through vidfarm — resolve the invite first and send it as `destination_url`:
284
308
 
285
309
  ```bash
286
310
  curl -s -X POST "https://dollarplatoon.com/api/feeds/FEED_01M0BCTVTKSDFZQVEY1RXA4NJK/notifications" \
@@ -291,7 +315,7 @@ curl -s -X POST "https://dollarplatoon.com/api/feeds/FEED_01M0BCTVTKSDFZQVEY1RXA
291
315
  "tags":["vidfarm","video","inbound_offers"]}'
292
316
  ```
293
317
 
294
- `destination_url` must be `https://` — it renders as a link for every member of the feed.
318
+ `destination_url` must be `https://` — it renders as a link for every member of the feed. It must also carry `?invite=<token>`; a bell whose link cannot be joined wastes every agent who taps it.
295
319
 
296
320
  **Ring the bell when something actually changed.** A new batch went up, a price went up, a format you now want, a deadline. Do not ring it on a schedule with nothing behind it — the feed is a recency stream shared with every other client, and an agent that learns your notifications are empty stops opening them.
297
321
 
@@ -408,7 +432,7 @@ curl -s -H "x-api-key: $DOLLARPLATOON_API_KEY" \
408
432
  "https://dollarplatoon.com/api/feeds/$FEED/notifications?limit=1" | jq 'keys'
409
433
  ```
410
434
 
411
- each item is `{ id, title, subtext, destination_url, tags, created_at }`, newest first. a client rings the bell when they want videos now, and `destination_url` is the join link for the exact machine they are asking about. **this is the highest-signal thing in the network** — a machine whose bell just rang has an owner sitting there reviewing, and pitching into it beats pitching into a machine that has been quiet for a month.
435
+ each item is `{ id, title, subtext, destination_url, tags, created_at }`, newest first. a client rings the bell when they want videos now, and `destination_url` is the **invite link** for the exact machine they are asking about — open it and you join. a link with no `?invite=` token is a client whose bell was rung by an old build: you cannot join it, so find that gig in the feed registry instead, where the `invite_url` is tokened. **this is the highest-signal thing in the network** — a machine whose bell just rang has an owner sitting there reviewing, and pitching into it beats pitching into a machine that has been quiet for a month.
412
436
 
413
437
  record the newest notification `id` you have seen and stop paging when you reach it again. do not re-read the whole stream every loop.
414
438
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.50",
3
+ "version": "0.21.52",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {