Skip to content

Producers & the Producer SPI

Producers are the agents that turn a blueprint into a posting proposal — and, if they declare a render surface, into the finished media. Today there is one: SimilarContent, a kind:clone agent that recreates a winning clip 1:1 and renders approved recipes into actual reels. Three more are planned from the same scaffold. This page documents the SPI (service provider interface) every producer must implement, walks through SimilarContent's full call sequence, and shows how to build a new producer with the copy-scaffold recipe.

Prerequisite reading

This page assumes familiarity with the hub's REST contract (API Reference) and the 8-stage pipeline (Architecture). The blueprint schema that producers consume is documented in Agents: AnalysisEngine.

What a producer is

A producer reads the shared corpus, blueprints, audio trends, and cross-agent insights, and writes a studio proposal — a markdown file describing exactly what to generate (or re-generate) for a platform. It never posts to the platform itself and never touches another agent's files. Every read and write goes through the hub's /api/* surface.

consumes: corpus, analysis (blueprints), audio, insights
produces: studio_markdown  →  POST /api/studio/{platform}
gate:     human approve/reject in the Dashboard
render:   (optional) approved item → media → POST /api/renders/{platform}

The proposal half is free — it reads blueprints and writes markdown. The render half costs money per frame, so it is a separate, human-triggered invocation, never part of a pipeline run.

Producer kind human_gate needs_reference Status
SimilarContent clone false false Live
proposal-content proposal true false Planned
creative-idea idea false Planned
template-content template true Planned

Human gate is universal, human_gate in the manifest is not

Every studio proposal is gated by a human approve/reject step in the Dashboard regardless of the manifest flag — that gate lives in the studio/{p}/gate.jsonl workflow, not in the producer. The manifest's human_gate field is closer to a declared intent/UX hint (e.g. proposal-content flags it explicitly because it's producing candidate scripts meant for human selection); it doesn't change the mechanics described below.

The Producer SPI

Every generation agent — SimilarContent today, proposal/idea/template producers tomorrow — implements the same contract with the hub: four mandatory parts, plus an optional fifth for producers that render.

1. Register

On startup, a producer self-registers its manifest. Registration is an idempotent upsert keyed by name, so restarting an agent, or redeploying it, never creates duplicate roster entries.

POST /api/producers/register
ProducerManifest
{
  "name": "similar-content",
  "kind": "clone",
  "consumes": ["corpus", "analysis", "audio", "insights"],
  "human_gate": false,
  "needs_reference": false,
  "produces": "studio_markdown",
  "output_status": "proposed",

  "renderable": true,
  "dir": "SimilarContent",
  "render_cmd": ["uv", "run", "cli.py", "render"],

  "proposes": true,
  "propose_cmd": ["uv", "run", "cli.py"],

  "config_schema": { "...": "..." },
  "secrets": [{ "name": "...", "env_var": "...", "required": true }],
  "workflow_stages": ["Queued", "Generating", "Self-eval", "Proposed", "Approved",
                      "Rendering", "Rendered", "Rejected"]
}

Two capabilities, deliberately separate

A producer can declare two independent launch capabilities. Both are optional, and a producer that only writes markdown by hand declares neither.

Capability Fields What it opts into Cost
Render renderable, dir, render_cmd POST /api/studio/{p}/{file}/render — turn one approved proposal into a media file Paid. Image-API credits per frame. Human-triggered only
Propose proposes, dir, propose_cmd POST /api/pipeline/{p}/propose — the propose pipeline stage, and the last boundary the cascade fires unattended Free. Reads blueprints, writes markdown

They share dir and are otherwise independent. Keeping them apart is the point: if one flag granted both, a producer that only wanted to be proposable would have to declare itself renderable, and the free unattended trigger would be gated on — and able to grant — a paid capability. The hub enforces the split at the resolver (_producer_dir(agent, capability=...)).

Declaring a render surface

The three render fields make a producer renderable — able to turn an approved proposal into an actual media file.

Field Meaning
renderable true opts this producer into POST /api/studio/{p}/{file}/render.
dir The producer's own directory name. Must be a direct sibling of the hub repo — no slashes, no leading dot. This is the working directory the command runs in.
render_cmd The argv the hub executes there. Must start with an allowlisted launcher (uv, python, python3, node, npm), and every argument must match ^[A-Za-z0-9._/=:-]{1,120}$ with no absolute path and no ...

The hub hardcodes no producer name or path: it resolves the producer from the studio item's agent field, then reads these fields from that agent's registered manifest. Adding a second renderable producer needs no hub change.

Declaring a propose surface

Field Meaning
proposes true opts this producer into the propose stage and the cascade's propose boundary.
dir The same field, and the same direct-sibling rule, as the render surface.
propose_cmd The argv prefix the hub runs there. Same launcher allowlist and argument shape-check as render_cmd.

propose_cmd is a prefix, not a command. The hub appends propose --platform <p> itself and refuses to take the subcommand from the manifest — it even strips a trailing propose if one is spelled out. That is the whole security argument for this stage: _validate_render_cmd checks argv shape, not semantics, so a producer allowed to name its own subcommand could declare ["uv","run","cli.py","render"] and reach a paid command through the free, unattended trigger. Registration is unauthenticated, so "free by construction" has to mean construction rather than the producer's honesty.

Zero registered proposers is no longer refused — it auto-heals. When no registered producer declares proposes: true, the hub falls back to launching the built-in similar-content proposer, resolved from its sibling directory under the same direct-sibling containment rule (_builtin_proposer_dir) and the same argv allowlist (_validate_render_cmd) as the registered path, running only the hub-owned default command — never anything from a manifest. That built-in proposer self-registers on its first run, so the next launch takes the ordinary registered path and the fallback stops firing. Readiness (_proposer_status, which greys the button with its reason) reports ready whenever either a registered proposer or the built-in proposer is available.

Only the "several proposers" case is still refused. Two or more producers declaring proposes: true is a genuine ambiguity the hub will not guess through unattended, so it is still rejected with a 409 — and that is the reason surfaced as propose_agent_problem in GET /api/cascade, which is what an operator sees when the last boundary can never fire. (Auto-heal deliberately does not paper over ambiguity — only absence.)

An implementation should honour --count N (how many recipes one firing publishes — the cascade passes it) and offer a --dry-run that shows the picks and writes nothing.

render_cmd is validated, not trusted

POST /api/producers/register is unauthenticated and ProducerManifest allows extra fields, so render_cmd is caller-supplied and ends up as argv for subprocess.run. The launcher allowlist and argument shape-check are the actual security boundary, not defence in depth — dir only pins the working directory, never the command. shell=True is never used. See SECURITY.md at the repo root.

The registry is what makes new producers appear on the Dashboard automatically — GET /api/producers drives the producer lanes shown on the board, so shipping a new producer is purely additive: register it, and the UI grows a lane with no frontend change.

GET /api/producers            # full roster
GET /api/producers/{name}     # one manifest

workflow_stages is the ordered lane list the agent board renders for this producer (see Agents: AutoSearch for the parallel discovery-kind board reducer). Approved and Rejected are always the last two stages and are never emitted by the agent itself — the hub derives them by left-joining the human gate decision log onto the board.

2. Consume corpus, analysis, audio, insights

Before generating anything, a producer reads four shared substrates, all via GET:

Substrate Endpoint Purpose
Corpus GET /api/corpus/{p}/top?n= Pick candidate targets by virality
Corpus GET /api/corpus/{p}/search?q=&k= Recall search when a topic is given
Content GET /api/content/{p} Full normalized record for a target
Analysis GET /api/analysis/{p} / GET /api/analysis/{p}/{content_id} The schema_version 2 blueprint — source of truth
Audio GET /api/audio/{p}/trending?reusable_only=&mood= Trending sounds when the blueprint's own sound isn't reusable
Audio GET /api/audio/{p}/sound/{audio_id} Detail for one sound
Insights GET /api/insights Prior transferable learnings from any agent

The blueprint (from stage 6, "Blueprint," in Architecture) is the shared substrate every producer reads: video_metadata, global_style, audio, audio_strategy, characters_and_subjects[], text_overlays[], shots[] (each shot carrying a generation_prompt/negative_prompt), regeneration_guide, virality_formula, evaluation. A producer should prefer the blueprint over reverse-engineering from raw content whenever one exists.

3. Produce studio_markdown

Once assembled, the proposal is written to the hub as markdown:

POST /api/studio/{platform}
Proposal
{
  "text": "...",
  "filename": "2026-07-18-similar-content-<slug>.md",
  "agent": "similar-content",
  "kind": "clone",
  "status": "proposed"
}

kind is one of clone | proposal | idea | template, matching the manifest's kind. The filename convention is <date>-<agent>-<slug>.md; a sidecar studio/{p}/meta.json tracks status/agent/kind alongside it.

Omitting status preserves it — it does not reset to proposed

The hub resolves the saved status as body.status or existing.status or "proposed". So proposed is the default only on a first insert; on a re-POST of an existing filename the current gate state is kept.

This is what makes re-posting safe. A renderable producer re-POSTs its own markdown after a render to stamp the finished media onto the item — without preservation that write would silently flip a human's approved back to proposed and un-gate it. Producers should omit status on re-POST for exactly this reason, and only ever send it deliberately.

The ## Audio block is mandatory

Every proposal markdown file must include a ## Audio section. Because sound selection frequently needs a human ear and can't be safely automated end-to-end, this section is a manual-post instruction block rather than an executable directive — see below.

4. Human gate

A proposal sits at status: "proposed" until a human reviews it in the Dashboard and calls:

POST /api/studio/{platform}/{file}/status
StatusUpdate
{ "status": "approved", "note": "optional reviewer note" }

This is appended to studio/{p}/gate.jsonl and is the only way a proposal moves to approved or rejected. The hub's GET /api/agents/{name}/board reducer left-joins this gate log by filename onto the producer's board, overwriting the Proposed lane with Approved/Rejected. The producer agent itself never emits those two events.

5. Render (renderable producers only)

Approved is no longer the end of the line. For a producer that declared renderable: true, approval is what unlocks the paid half:

POST /api/studio/{p}/{file}/render     # body: {"force": false}

The hub resolves the producing agent from the item, validates its dir and render_cmd, and launches it as a background job. The producer generates the media and uploads it:

POST /api/renders/{p}                  # RenderIn: file (join key) + metadata + base64 assets

Rules the hub enforces:

  • 409 unless the item is approved — and 409 if it has no producing agent recorded.
  • One render per studio item. The render_id is derived server-side from the studio filename, so re-rendering overwrites in place and path traversal is impossible at the source.
  • A per-item lock. The job id is deterministic ({platform}:render:{file}); a second call while one is in flight returns already_running: true rather than starting a duplicate paid render.
  • Never in run-all. Rendering costs money per frame, so it is excluded from the one-click pipeline and only ever runs when a human presses the button.

Generated media lands in renders/{p}/{render_id}/, served at /renders — structurally separate from /media, which is the scraped corpus. See API Reference → Renders.

How the media is made is up to the producer — including video-to-video

The contract is only "produce an approved item's media and POST /api/renders/{p}." SimilarContent generates still frames with an image model and stitches them with ffmpeg, but nothing here requires that. A video-to-video producer can call a video model (e.g. Google Veo / Flow), get an .mp4 directly, and upload it — no image generation, no ffmpeg. It uploads the finished clip (plus a poster) as the render assets instead of per-frame stills; the base64 upload is capped at 64 MB decoded (MAX_RENDER_BYTES, HTTP 413 over that), which fits a typical 20–40 MB clip. Such an agent is spun up from _producer-template/ exactly like any other — it just declares renderable: true and points render_cmd at its own render subcommand.

Output shape knobs

A renderable producer declares its canvas in config_schema, so it is editable from the Dashboard. SimilarContent's two:

Knob Default Values Meaning
aspect_ratio 9:16 9:16, 4:5, 1:1 The output canvas. 9:16 (1080×1920) is the reels/shorts/tiktok format and the only one that fills a phone full-bleed. 4:5 = 1080×1350 (IG feed portrait), 1:1 = 1080×1080.
video_fit auto auto, cover, contain How a generated frame meets that canvas. auto crops when the frame is within 10% of the canvas aspect and letterboxes when it is further out; cover always crops; contain always letterboxes. None of them ever stretch.

No width/height knob, deliberately

The canvas is derived from aspect_ratio, so the output can never be a size that disagrees with the aspect it claims to be.

Choosing what gets cloned: the ease gate

propose ranks candidates by how cheap each winner is to remake, not only by how well it performed. That judgement is one function, SimilarContent/engine/propose.py::score_ease, scoring 0–100 on three continuous terms — fewer shots, shorter, less camera movement:

shots     45 / (1 + (n-1))            1 -> 45   2 -> 22.5   6 -> 7.5
duration  30 * clamp((30 - d) / 25)   6s -> 28.8   9.4s -> 24.7   >=30s -> 0
camera    20 * (static shots / shots with known camera data)

Every term is continuous on purpose: one extra shot, one extra second and one moving shot each cost something, so two genuinely different clips can never land on the same number. (They used to. The terms were bands, the shot band stopped at four, and on a real corpus of 6–7 shot ~10s reels every candidate scored exactly 40 against a gate of 55 — nothing ever cleared, and the run silently served "most viral" to someone who asked for "easiest to remake".)

Knob Default Meaning
ease_threshold 55 Score at or above which a candidate is "easy enough".
ease_restore_to null Where the threshold came from, recorded automatically when you lower it, so a restore has a target.
ease_auto_restore false Opt in to letting a run hand the threshold back on its own.
backfill_order virality When too few candidates clear the gate, whether the remainder is ranked by virality or by ease.

The gate reports on itself. Every run says what it did, in the terminal and as a structured POST /api/logs event — so a starved gate is something you see rather than something you infer:

  • starved0 of 15 candidates cleared ease >= 55 — best score was 52.18. Lower ease_threshold to 52 to rank by ease. It never lowers anything itself.
  • restore-ready12 of 15 candidates now clear ease >= 55 — restore ease_threshold to 55.

Automation may only ever RAISE the threshold

Only a human lowers the gate. A wrong restore costs a few easy picks and is visible and reversible in one edit; a wrong auto-lowering would quietly admit clips nobody would call easy, and every recipe would still look right. The raise is a max(), so a lower value is not representable in its output, and the write is a compare-and-set — a restore computed at run start cannot overwrite a threshold you moved while the run was fetching blueprints.

Sequence: register → propose → gate → render

sequenceDiagram
    participant P as Producer (e.g. SimilarContent)
    participant Hub as Hub (:8787)
    participant D as Dashboard (human gate)

    P->>Hub: POST /api/producers/register (manifest)
    Hub-->>P: 200 (upserted, producers/registry.json)

    P->>Hub: GET /api/corpus/{p}/top / search
    Hub-->>P: candidate targets
    P->>Hub: GET /api/analysis/{p}/{content_id}
    Hub-->>P: blueprint (schema_version 2)

    Note over P: generate visuals locally,<br/>assemble proposal markdown

    P->>Hub: POST /api/studio/{p} {text, agent, kind, status:"proposed"}
    Hub-->>P: 200 (studio/{p}/<file>.md + meta.json)

    D->>Hub: GET /api/studio/{p}?status=proposed
    Hub-->>D: proposal list
    Note over D: human reviews proposal in Dashboard

    D->>Hub: POST /api/studio/{p}/{file}/status {status:"approved"|"rejected"}
    Hub-->>D: 200 (studio/{p}/gate.jsonl appended)

    Note over Hub,D: Approved unlocks render<br/>(gate lane derived, never emitted by P)

    D->>Hub: POST /api/studio/{p}/{file}/render
    Hub->>P: launch render_cmd in dir (validated argv)
    Note over P: generate frames, stitch, caption<br/>(paid — human-triggered only)
    P->>Hub: POST /api/renders/{p} {file, metadata, assets[]}
    Hub-->>P: 200 (renders/{p}/{render_id}/ + video_url)
    D->>Hub: GET /api/renders/{p}
    Hub-->>D: rendered reels, playable inline at /renders

Per-item events emitted by the producer along this sequence: item.start (Generating) → item.stage (Self-eval) → item.done (Proposed, must include data.file — the join key the board's gate-join reducer uses) → external gateApproved/Rejected (derived only). item.error → the implicit Failed lane on any failure. See Agents: Dashboard for how the board folds live SSE log events onto this reduced snapshot.

The ## Audio manual-post block

Because sound licensing, trend timing, and platform-native audio selection are hard to fully automate, every studio proposal's markdown carries a ## Audio section written as an instruction to the human operator posting the clip, not as a machine-actionable field.

Excerpt from a studio proposal
## Audio

Use the original sound from the source clip if `audio_strategy` marks it
reusable; sound_id `<audio_id>`, title "<title>". If not reusable, use a
trending Rising/Hot sound instead — see the platform's Sounds panel in the
Dashboard for the ranked shortlist, then attach it manually when posting.

Do not auto-attach: verify the sound is still available on the target
platform before publishing.

The producer arrives at this recommendation by branching on the blueprint's audio_strategy field: if the source sound is reusable, the block names it directly; otherwise the producer calls GET /api/audio/{p}/trending?reusable_only=&mood= (optionally GET /api/audio/{p}/sound/{audio_id} for detail) and recommends a trending alternative. Either way, the actual attach-to-post action is manual — the pipeline stops at "propose," never "publish."

Why this is manual, not automated

Trending-sound relevance windows are short and platform sound catalogs aren't reliably reachable via API for attach actions. Rather than silently guess or risk a broken/expired sound reference, the SPI treats audio selection as a recommendation surfaced to the human at post time.

Copy-scaffold recipe: building a new producer

New producers are spun up from _producer-template/, never written from scratch. This keeps every producer's registration, config, logging, and secrets handling identical, which is what lets the Dashboard render new producer lanes automatically.

cp -r _producer-template MyNewProducer
cd MyNewProducer
  1. Edit agent.json — the manifest: set name, kind, consumes, produces (almost always studio_markdown), output_status (almost always proposed), human_gate, needs_reference, config_schema, secrets[], and workflow_stages[].

    If the producer will also render approved items into media, add the three render fields: renderable: true, dir (this directory's name — it must be a direct sibling of the hub repo), and render_cmd (an allowlisted launcher plus plain arguments, e.g. ["uv", "run", "cli.py", "render"]). Extend workflow_stages[] past Approved accordingly — SimilarContent uses …, "Approved", "Rendering", "Rendered", "Rejected". If it only writes markdown, omit all three. 2. Edit CLAUDE.md — describe the producer's specific generation method (what it reads, what transform it applies, what it writes) in terms of the SPI above. 3. Review memory/MEMORY.md, persona.md, patterns.md ship as empty scaffolding; the agent accumulates learnings here across runs (local, not synced to the hub except via POST /api/insights). 4. Check .claude/settings.local.json — the hub-only allowlist; confirm it still matches your BACKEND_API expectations. 5. Copy .env.example.env and fill in secret values — referenced by env-var NAME only in agent.json's secrets[]; the hub never stores the value, only presence (GET /api/config/agent/{agent}/secrets/status). 6. Open a session and implement the per-run method: register → consume corpus/analysis/audio/insights → generate → self-eval → POST /api/studio/{p} → emit lifecycle logs with data.file on item.done. A renderable producer adds a second, separately-invoked path: read one approved item, produce the media, POST /api/renders/{p}.

_producer-template/
├── CLAUDE.md
├── agent.json                    # manifest (kind, consumes, produces, human_gate, ...)
├── logsetup.py
├── memory/
│   ├── MEMORY.md
│   ├── persona.md
│   └── patterns.md
├── .claude/settings.local.json   # hub-only allowlist
├── .env.example
└── .gitignore

Integrate via BACKEND_API, never a hardcoded path

Every producer, including new ones built from the scaffold, must read the hub's base URL from the BACKEND_API env var (default http://127.0.0.1:8787). This is the forward-compat rule that keeps the hub the single integration point — see Architecture.

The three future producers

These are not yet built, but are scoped precisely enough to spin up from the template on demand.

proposal-content (kind: proposal)

Generates N original script proposals grounded in the corpus's winning factors, rather than cloning one specific clip. Manifest declares human_gate: true explicitly, signaling that the output is a set of candidate scripts intended for human selection among alternatives — distinct from SimilarContent's single best-effort recreation. Otherwise follows the identical SPI: reads GET /api/corpus/{p}/factors and GET /api/corpus/{p}/brief for grounding, writes one or more kind:"proposal" studio proposals.

creative-idea (kind: idea)

Produces net-new viral concepts by cross-referencing the corpus's virality factors and formulas with trending audio, rather than adapting any single existing clip. Reads GET /api/corpus/{p}/factors, blueprint virality_formula fields across multiple analyses, and GET /api/audio/{p}/trending to find concept/sound pairings not yet represented in the corpus. Writes kind:"idea" studio proposals.

template-content (kind: template, needs_reference: true)

The only planned producer that needs external material. An operator supplies a reference video (not from the tracked corpus) via:

POST /api/reference/{platform}   { "url": "...", "note": "..." }

The hub downloads the media (yt-dlp if available, else a direct GET — never login/cookie-based), assigns a synthetic ref_<hash> id, and marks it pending. AnalysisEngine picks this up from the reference queue (GET /api/reference/{p}/pending, alongside its normal blueprint queue) and writes a blueprint for it with is_reference: true, served at the same GET /api/analysis/{p}/{ref_id} route as ordinary blueprints. template-content then reads that reference blueprint's structure and applies it to the operator's own topic, producing a kind:"template" studio proposal. Reference videos are explicitly not corpus content — they are not scored and not treated as a real tracked reel.

Reference

Concept Detail
Registration POST /api/producers/register, idempotent upsert by name
Roster GET /api/producers, GET /api/producers/{name}
Proposal write POST /api/studio/{platform}{text, filename?, agent?, kind?, status?}
Proposal list GET /api/studio/{platform}?status=&agent=
Human gate POST /api/studio/{platform}/{file}/status{status, note?}
Gate log studio/{p}/gate.jsonl
Sidecar metadata studio/{p}/meta.json
Terminal join key data.file on the producer's item.done log event
kind values clone \| proposal \| idea \| template

See also