Content pipelines: research, script, voice, images and subtitles from one wallet
A content pipeline turns a topic into a finished piece: find the facts, write the script, voice it, illustrate it, transcribe it, subtitle it, dub it and check the files. Here each stage is one paid call to the same service, paid from one wallet over x402 or MPP, with no API key and no account to open.
Most stages are model-backed: a model answers, writes, speaks, draws, listens or translates. Subtitle conversion and the media checks are deterministic code.
The stages
| Stage | Slug | Price | Model-backed | Runs as a job |
|---|---|---|---|---|
| Answer with citations | answer |
$0.08 | yes | no |
| Research report | research |
$0.60 | yes | yes, required |
| Script | v1-chat-metered |
quoted per request, from $0.001 | yes | no |
| Voiceover | tts-lite |
$0.005 | yes | no |
| Voiceover | tts, tts-hd |
quoted from the text, from $0.005 and $0.010 | yes | no |
| Images | v1-images-fast, v1-images-pro |
$0.020, $0.050 | yes | on request |
| Image edit | v1-images-edit |
$0.020 | yes | no |
| Transcription | transcribe, transcribe-pro |
$0.030, $0.100 | yes | no |
| Subtitles | srt-convert |
$0.002, or free with proof-of-work | no | no |
| Subtitles in one call | skill-subtitle-pipeline |
$0.03 | yes | no |
| Dubbing | dub |
quoted by tier, from $0.140 | yes | no |
| Media probe | media-info |
$0.005 | no | no |
| Audio track to MP3 | audio-convert |
$0.02 | no | no |
| Loudness | audio-normalize |
$0.02 | no | no |
Prices are read from /api/pricing when this page renders. Each example below is the tool's published example.
Set up once
One client pays every stage. The caps are checked before each payment is signed, against the quote for that request.
import { Agent402 } from "agent402-client";
import { wrapFetchWithPayment } from "@x402/fetch";
import { x402Client } from "@x402/core/client";
import { registerExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";
const client = new x402Client();
client.setSpendControls?.(false); // the caps below replace the library default
registerExactEvmScheme(client, { signer: privateKeyToAccount(process.env.AGENT_KEY) });
const payFetch = wrapFetchWithPayment(fetch, client);
const a = new Agent402({
fetch: payFetch,
x402Client: client, // the caps are checked on the payment that is signed
maxPerCallUsd: 1, // refuse any single quote above this
dailyLimitUsd: 25, // rolling 24 hours, this instance
});
Over plain HTTP, the same request without a payment answers 402 with the quote, and your x402 or MPP client pays it:
curl -i -X POST https://agent402.tools/api/tts \
-H 'Content-Type: application/json' \
-d '{"text":"Hello from Agent402!","voice":"alloy","format":"mp3"}'
# HTTP/2 402, the x402 accepts in PAYMENT-REQUIRED and the MPP challenges in WWW-Authenticate
1. Research
answer (GET /api/answer) returns a short answer grounded in live web
search, with its citations:
const brief = await a.call("answer", { q: "what is the x402 payment protocol?" });
// { query, answer, citations: [{ url, snippet, number }], citationCount }
research (POST /v1/research) plans sub-questions, runs several searches
and returns one cited report. It runs as a job: the client sends
Prefer: respond-async and collects the report from the job link.
const report = await a.call("research", { query: "What are the leading agentic-payment protocols in 2026 and how do they differ?", recency: "year" });
// { report, sources: [{ n, title, url, snippet }], sub_questions, meta }
2. Script
The metered chat route quotes each request from its input and max_tokens,
so a short script costs what a short script uses. Name any id from
/v1/models in model, or leave it out for
the default.
const messages = [
{ role: "system", content: "Write a 60-second voiceover script in plain sentences." },
{ role: "user", content: brief.answer },
];
const draft = await a.call("v1-chat-metered", { max_tokens: 600, messages });
const script = draft.choices[0].message.content;
The flat tiers take the same body at one price per call, from
/v1/nano/chat/completions ($0.003) up.
3. Voiceover
const voice = await a.call("tts", { text: "Hello from Agent402!", voice: "alloy", format: "mp3" });
const audio = Buffer.from(voice.audio, "base64"); // { model, voice, format, audio, chars }
- Tiers.
tts-litefor high-volume narration where timbre matters less,ttsfor natural speech,tts-hdfor the most expressive delivery (it reads inline tags such as[whispering]as cues). One request shape for all three. - Length. 2,000 characters a call on
ttsandtts-hd, 800 ontts-lite. Split a longer script at sentence ends and send one call per part. - Voices. The ten standard names on every tier:
alloy,ash,ballad,coral,echo,fable,nova,onyx,sage,shimmer.ttsandtts-hdalso take the named voices listed on /speech. - Formats.
mp3,opus,aac,flac,wav,pcmonttsandtts-hd;mp3orpcmontts-lite.
4. Images
/v1/images/fast and /v1/images/pro take a prompt and return one picture
inline on the OpenAI images wire; pro is the higher-fidelity tier.
const cover = await a.call("v1-images-fast", { prompt: "A minimalist line drawing of a lighthouse at dawn" });
const jpeg = Buffer.from(cover.data[0].b64_json, "base64");
/v1/images/edits changes a picture you already have: send one to four PNG
or JPEG references (base64 or data: URLs) and describe the change in the
prompt. The output follows the first reference's aspect ratio.
const ref = "data:image/jpeg;base64," + cover.data[0].b64_json;
const edited = await a.call("v1-images-edit", { prompt: "Add a warm sunrise glow behind the lighthouse", image: ref });
5. Transcription
transcribe reads audio from a public URL, up to 4 minutes
(transcribe-pro up to 10) and 25 MB. Add
diarize: true for speaker labels and word timestamps, which the subtitle
stage turns into timed cues.
const t = await a.call("transcribe", { url: "https://agent402.tools/fixtures/sample-speech.wav", diarize: true });
// { text, language, duration, speakers, words: [{ word, start, end, speaker }] }
For audio you hold as bytes, POST /v1/audio/transcriptions takes the same
models as a multipart file upload (/v1/pro/audio/transcriptions for the
longer cap).
6. Subtitles
srt-convert turns timed cues into SRT, WebVTT, plain text or JSON. It is
pure CPU, so agent402-client pays it with a proof-of-work solution and no
wallet.
const words = t.words.filter((w) => w.word.trim() && w.start != null);
const cues = [];
for (let i = 0; i < words.length; i += 7) {
const line = words.slice(i, i + 7);
cues.push({ start: Math.round(line[0].start * 1000), end: Math.round(line.at(-1).end * 1000), text: line.map((w) => w.word).join(" ") });
}
const subs = await a.call("srt-convert", { cues, to: "srt" });
// { detected, to, count, result }: result is the SRT file
The subtitle-pipeline skill pack runs transcribe, srt-convert and text-stats in one payment. Its file is one cue spanning the clip, so use the diarized words above when each line needs its own timing.
const pack = await a.call("skill-subtitle-pipeline", { url: "https://agent402.tools/fixtures/sample-speech.wav", format: "vtt" });
7. Dubbing
dub translates a text and speaks the translation in one call. tier
"hd" speaks it on the tts-hd voice, and the quote is the sum of the two
steps. The text cap applies to the translation.
const es = await a.call("dub", { text: "Hello from Agent402! Every tool is paid per call.", targetLanguage: "es", voice: "nova", format: "mp3", json: true });
// { translation, detectedSourceLanguage, targetLanguage, tier, voice, format, audio, chars }
Without json: true the answer is the audio bytes, with the translation in
the X-Dub-Translation header (base64 JSON).
8. Media checks
Each reads a public URL and runs ffprobe or ffmpeg on it.
const info = await a.call("media-info", { url: "https://agent402.tools/fixtures/sample-audio.wav" });
// { formatName, durationSec, bitrate, bytes, streams: [{ type, codec, sampleRate, channels }] }
const level = await a.call("audio-normalize", { url: "https://agent402.tools/fixtures/sample-audio.wav", targetLufs: -16 });
// { format: "mp3", targetLufs, truePeakDb, bytes, mp3Base64 }
const track = await a.call("audio-convert", { url: "https://agent402.tools/fixtures/sample-audio.wav", bitrate: "192k" });
// { format: "mp3", bitrate, bytes, mp3Base64 }: the audio track of a video, as MP3
End to end
The stages that read a URL (transcription, the media checks) need the audio at a public address, so the flow puts each file where your pipeline already keeps its media. Stages that need only the script run side by side.
// Your storage: put the bytes somewhere public and return the URL.
async function publish(name, bytes) {
throw new Error("upload " + name + " (" + bytes.length + " bytes) and return its public URL");
}
const topic = "what is the x402 payment protocol?";
const brief = await a.call("answer", { q: topic });
const messages = [
{ role: "system", content: "Write a 60-second voiceover script in plain sentences." },
{ role: "user", content: brief.answer },
];
const draft = await a.call("v1-chat-metered", { max_tokens: 600, messages });
const script = draft.choices[0].message.content;
const [voice, cover, es] = await Promise.all([
a.call("tts", { text: script, voice: "nova", format: "mp3" }),
a.call("v1-images-fast", { prompt: "Cover art as a minimalist line drawing: " + topic }),
a.call("dub", { text: script, targetLanguage: "es", voice: "nova", format: "mp3", json: true }),
]);
const rawUrl = await publish("episode-raw.mp3", Buffer.from(voice.audio, "base64"));
const level = await a.call("audio-normalize", { url: rawUrl, targetLufs: -16 });
const episodeUrl = await publish("episode.mp3", Buffer.from(level.mp3Base64, "base64"));
const info = await a.call("media-info", { url: episodeUrl });
const t = await a.call("transcribe", { url: episodeUrl, diarize: true });
const words = t.words.filter((w) => w.word.trim() && w.start != null);
const cues = [];
for (let i = 0; i < words.length; i += 7) {
const line = words.slice(i, i + 7);
cues.push({ start: Math.round(line[0].start * 1000), end: Math.round(line.at(-1).end * 1000), text: line.map((w) => w.word).join(" ") });
}
const subs = await a.call("srt-convert", { cues, to: "srt" });
console.log(info.durationSec, subs.count, es.translation, a.spendingSummary());
Cite the sources brief.citations names in the episode's show notes;
a.spendingSummary() totals what the run spent.
Pipeline notes
Slow stages and jobs
A route runs as a job when its /api/pricing
row carries an async block. In this recipe:
researchrequires one: sendPrefer: respond-async(202 and a job link at once) orPrefer: wait=<maxSeconds>; a paid call with neither is answered 400 before any payment. agent402-client sends the header for you.v1-images-fastandv1-images-protakePrefer: respond-asyncon request. They answer in seconds, so waiting on the connection also works.- Every other stage answers on the connection, and the header is ignored there.
Poll GET /api/jobs/{id} (free), waiting Retry-After between polls,
until status is completed or failed. A client address runs
4 jobs at once, so a batch of research reports is a queue
on your side.
Spend caps
maxPerCallUsd, dailyLimitUsd and maxPerHostUsd are checked before a
payment is signed. On the quoted stages (tts, tts-hd, dub, metered
chat) the check reads the quote for that request, so a longer script meets
the cap before it is paid. A refused call throws SpendingLimitError.
The caps live in one client instance: give each worker its own.
In agent402-mcp, AGENT402_MAX_PER_CALL and AGENT402_BUDGET set the
per-call and session ceilings.
At volume
A pipeline that renders hundreds of episodes meets the per-address limits,
retries and replay rules on buying at volume:
send an Idempotency-Key per stage so a retried stage returns its first
answer instead of paying twice, and wait Retry-After on a 429 or 503.
Choosing a rail
research, v1-images-fast, v1-images-pro and v1-images-edit run long enough that they take x402 exact on
EVM chains only; their /api/pricing rows list those networks. Every other
stage here takes every offered chain, over x402 or MPP.
| Rail | When it settles | A stage that fails |
|---|---|---|
| x402 exact on an EVM chain | after the tool answers, only for a response under 400 | not charged |
| x402 exact on Solana | after the tool answers, only for a response under 400 | not charged |
MPP evm charge |
the same x402 settlement underneath | not charged |
| MPP Tempo pull (a signed transaction) | broadcast after the tool answers, only for a response under 400 | not charged on Tempo pull: the transaction is never broadcast |
| MPP Tempo push (a transfer already sent) | final before the call runs | the transfer is booked as a refund owed to the paying wallet, repaid after on-chain proof |
Tempo is offered on every stage except research, v1-images-fast, v1-images-pro and v1-images-edit. A
pull credential (payload type transaction) is a signed transaction
the relay broadcasts only once the stage has answered under 400, so a stage
that fails is never broadcast. A push credential (payload type hash)
is a transfer you already sent: it is final before the stage runs, so a stage
that fails then is repaid from the refund ledger. For a pipeline, pay with
pull credentials: pin mode: "pull" on the Tempo charge method, since a
JSON-RPC wallet account sends push by default. If your connection closes
before the answer, a pull payment can still be broadcast, and it is then
booked as owed in the same way.
import { Mppx, tempo } from "mppx/client";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.AGENT_KEY);
const mpp = Mppx.create({ methods: [tempo.charge({ account, mode: "pull" })], polyfill: false });
const res = await mpp.fetch("https://agent402.tools/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Hello from Agent402!", voice: "alloy", format: "mp3" }),
});
console.log(res.status, res.headers.get("payment-receipt"));
The agent402-client caps read x402 quotes. On Tempo, compare each challenge's amount with your own ceiling before your MPP client pays it. The MPP methods this instance offers are listed at /.well-known/x402, and the wire itself is in x402 and MPP.