Skip to content

Generate images, video and speech

The recipe uses the shipped a2e style, examples/media-styles/a2e.json, which supports image, video and speech. It reads A2E_API_KEY from the environment when it sends a request, and never logs it. Every a2e verb is async: the engine starts the job and polls it for you. Measured live: an image takes about 30 s, speech about 30 s, a 5-second video about 4 minutes. Pass onProgress if you want to show that progress to someone. The full concept is on the Media generation page.

1. Your code: image, then video, then speech

Section titled “1. Your code: image, then video, then speech”

The video takes the image’s URL as its starting frame. You chain the two calls by passing a URL, nothing more.

import { loadMediaStyle, createMedia } from "toolnexus"
const media = createMedia(loadMediaStyle("examples/media-styles/a2e.json"), {
onProgress: ({ verb, jobId, status }) => console.log(verb, jobId, status),
})
const img = await media.image({ prompt: "a red fox in fresh snow, golden hour", width: 1024, height: 768 })
const vid = await media.video({ prompt: "the fox turns its head and blinks", image: img.files[0].url, seconds: 5 })
const say = await media.speech({ text: "Good morning from the forest." })
console.log(img.files[0].url, vid.files[0].url, say.files[0].url)

Every verb returns the same shape: files (each {type, mimeType, url}), status, plus jobId for async styles and text for transcribe. A failure is an error (an exception, a Result::Err, an {:error, msg} tuple), and the message is the same in every language: job 66f… failed: <reason>, HTTP 401: <body>, image: argument "aspectRatio" not supported by this style.

Give the toolkit the same media. The model now sees generate_image, generate_video and generate_speech, with schemas that list only the arguments a2e accepts. When it calls them, everything they produce lands in RunResult.files, in call order and tagged with the tool name. The answer text never gets markup for them, so you decide how to show them.

import { createToolkit, createClient } from "toolnexus"
const tk = await createToolkit({ media })
const agent = createClient({ baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" })
const res = await agent.run("Make a picture of a fox in snow, then a 5 second video of it blinking.", { toolkit: tk })
console.log(res.text)
for (const f of res.files) console.log(f.from, f.mimeType, f.url)

A provider error never crashes the run. The tool returns isError: true with the same message your code would have got, and the model sees it and can retry or explain. In the live a2e run, a missing negative_prompt came back as HTTP 400 …. The fix was one line in the style’s defaults, with no code change.

createToolkit({ media }) is shorthand for toolkit.register(...media.tools()). Media tools register after A2A agent tools and before extraTools, and the first tool with a name wins, as for every other source.

RunResult.files is plain data, so each host renders it its own way:

  • Web. image/* goes in an <img>, video/* in a <video controls>, audio/* in an <audio controls>.
  • WhatsApp / Telegram. Send each URL as a media message, using mimeType to pick image, video or voice note.
  • CLI. Print from, mimeType and the URL, or download it to disk (do this soon, because a2e URLs expire).

Every port ships one runnable media example: an image through the typed verb, then generate_speech called straight off tools(). Without A2E_API_KEY it prints a one-line hint and exits 0, so it never touches the network in CI.

cd js && npm run build && A2E_API_KEY=... npm run example:media (js/examples/media.ts)

Next: write your own style for another provider.