Agent API
Send reference material and instructions; the video agent decides everything (story, shots, prompt, canvas), renders, optionally reviews the result against your instructions and tries again, and hands back one or more videos. No questions are asked back and nothing needs confirming.
Every call is authenticated with an API key. Create one at www.happypony.io → API Keys; the full key is shown once, so store it somewhere safe. Revoke a leaked key there and create a new one.
Authorization: Bearer hp_…
Session cookies are not accepted on these endpoints. Runs belong to the key's
user: another user's run id answers 404.
Base URL below: https://api.happypony.io.
| Endpoint | What it does |
|---|---|
POST /api/v1/agent/runs/uploads |
Upload a local file; returns a URL to use in a run |
POST /api/v1/agent/runs |
Start a run |
GET /api/v1/agent/runs/:id/status |
Poll for status |
GET /api/v1/agent/runs/:id |
Get the result: videos, credits, parameters, log |
PUT /api/v1/agent/runs/:id/cancel |
Cancel |
Quick start
The example below is a complete run: two reference images, one prompt, two
independent takes (variants: 2), each reviewed and redone once
(feedback_loops: 1). You need curl and jq; the full examples
at the end of this section do the same in Python, TypeScript and Node.js.
1. Get an API key
Create a key at www.happypony.io → API Keys and export it. The full key is shown once.
export HP_KEY=hp_…
2. Your images
Reference images are passed by URL. Either:
Use any public
https://image URL as it is. These two long-lived samples work right now:https://resource.pipelet.net/images/test/selfie3.pnghttps://resource.pipelet.net/images/test/selfie5.png
Or upload a local file and use the URL it returns:
curl -X POST https://api.happypony.io/api/v1/agent/runs/uploads \ -H "Authorization: Bearer $HP_KEY" -F file=@me.png # → {"url":"https://…signed…","kind":"image","expires_in":7200}That
urlis a presigned link, private to you and valid for 2 hours. It only has to be valid until you start the run: the file is copied into the run at that moment, so it does not matter that the link expires later.
3. Start the run
RUN=$(curl -s -X POST https://api.happypony.io/api/v1/agent/runs \
-H "Authorization: Bearer $HP_KEY" -H "Content-Type: application/json" \
-d '{
"prompt": "The two girls have a bar fight",
"reference_image_urls": [
"https://resource.pipelet.net/images/test/selfie3.png",
"https://resource.pipelet.net/images/test/selfie5.png"
],
"variants": 2,
"feedback_loops": 1
}')
echo "$RUN"
STATUS_URL=$(jq -r .status_url <<<"$RUN"); RESPONSE_URL=$(jq -r .response_url <<<"$RUN")
The 202 response carries request_id, status_url, response_url and
cancel_url. Use the last three as they are.
4. Poll the status
Every 15 seconds or so, until status is COMPLETED, FAILED or CANCELLED
(a run takes several minutes):
curl -s -H "Authorization: Bearer $HP_KEY" "$STATUS_URL" | jq '{status, phase, credits_used}'
5. Get the result and download the videos
curl -s -H "Authorization: Bearer $HP_KEY" "$RESPONSE_URL" > result.json
jq -r '.videos[] | select(.final) | "\(.variant) \(.url)"' result.json |
while read -r v url; do curl -s -o "video-$v.mp4" "$url"; done
videos[].final marks the render delivered for each variant; total_credits
is what the run cost. The video urls are presigned for 2 hours: to download
later, fetch response_url again for fresh links.
Full examples
Each one reads the key from HP_KEY, starts the run above, polls every 15
seconds, prints the credits and saves every video. upload() is only needed for
a local file: pass its result in reference_image_urls instead of a sample URL.
curl (bash)
#!/usr/bin/env bash
# Needs curl and jq. Usage: HP_KEY=hp_… ./run.sh
set -euo pipefail
API=https://api.happypony.io/api/v1/agent/runs
AUTH="Authorization: Bearer $HP_KEY"
call() { # prints the body; on an HTTP error prints it to stderr and fails
local out code body
out=$(curl -sS -w '\n%{http_code}' -H "$AUTH" "$@")
code=${out##*$'\n'}; body=${out%$'\n'*}
[[ $code == 2* ]] || { echo "HTTP $code: $body" >&2; return 1; }
echo "$body"
}
upload() { call -X POST "$API/uploads" -F "file=@$1" | jq -r .url; } # optional: local file → URL
RUN=$(call -X POST "$API" -H 'Content-Type: application/json' -d '{
"prompt": "The two girls have a bar fight",
"reference_image_urls": [
"https://resource.pipelet.net/images/test/selfie3.png",
"https://resource.pipelet.net/images/test/selfie5.png"],
"variants": 2, "feedback_loops": 1}')
STATUS_URL=$(jq -r .status_url <<<"$RUN"); RESPONSE_URL=$(jq -r .response_url <<<"$RUN")
while :; do
STATUS=$(call "$STATUS_URL" | jq -r .status); echo "status: $STATUS"
case $STATUS in COMPLETED|FAILED|CANCELLED) break ;; esac
sleep 15
done
call "$RESPONSE_URL" > result.json
echo "total credits: $(jq .total_credits result.json)"
jq -r '.videos[] | "\(.variant) \(.iteration) \(.final) \(.url)"' result.json |
while read -r v i final url; do
curl -sS --fail -o "video-v$v-i$i.mp4" "$url" && echo "saved video-v$v-i$i.mp4 (final=$final)"
done
Python
# pip install requests | HP_KEY=hp_… python run.py
import os, sys, time, requests
API = "https://api.happypony.io/api/v1/agent/runs"
HEADERS = {"Authorization": f"Bearer {os.environ['HP_KEY']}"}
def check(r: requests.Response) -> dict:
if not r.ok:
sys.exit(f"HTTP {r.status_code}: {r.text}")
return r.json()
def upload(path: str) -> str: # optional: local file -> presigned URL (valid 2 h)
with open(path, "rb") as f:
return check(requests.post(f"{API}/uploads", headers=HEADERS, files={"file": f}))["url"]
run = check(requests.post(API, headers=HEADERS, json={
"prompt": "The two girls have a bar fight",
"reference_image_urls": [
"https://resource.pipelet.net/images/test/selfie3.png",
"https://resource.pipelet.net/images/test/selfie5.png",
],
"variants": 2,
"feedback_loops": 1,
}))
while True:
status = check(requests.get(run["status_url"], headers=HEADERS))["status"]
print("status:", status)
if status in ("COMPLETED", "FAILED", "CANCELLED"):
break
time.sleep(15)
result = check(requests.get(run["response_url"], headers=HEADERS))
print("total credits:", result["total_credits"])
for v in result["videos"]:
name = f"video-v{v['variant']}-i{v['iteration']}.mp4"
r = requests.get(v["url"]) # presigned: no auth header
r.raise_for_status()
open(name, "wb").write(r.content)
print("saved", name, "final" if v["final"] else "")
TypeScript
// Node 18+ (or Deno/Bun). Run with: HP_KEY=hp_… npx tsx run.ts
import { readFile, writeFile } from "node:fs/promises";
import { basename } from "node:path";
const API = "https://api.happypony.io/api/v1/agent/runs";
const AUTH = { Authorization: `Bearer ${process.env.HP_KEY}` };
interface Created { request_id: string; status_url: string; response_url: string; cancel_url: string }
type Status = "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "CANCELLED";
interface Video { variant: number; iteration: number; final: boolean; url: string; credits: number; prompt_used: string }
interface Result { status: Status; videos: Video[]; total_credits: number }
async function call<T>(url: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(url, { ...init, headers: { ...AUTH, ...init.headers } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
// Optional: upload a local file, get a presigned URL (valid 2 h) for reference_image_urls.
async function upload(path: string): Promise<string> {
const form = new FormData();
form.append("file", new Blob([await readFile(path)]), basename(path));
return (await call<{ url: string }>(`${API}/uploads`, { method: "POST", body: form })).url;
}
const run = await call<Created>(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: "The two girls have a bar fight",
reference_image_urls: [
"https://resource.pipelet.net/images/test/selfie3.png",
"https://resource.pipelet.net/images/test/selfie5.png",
],
variants: 2,
feedback_loops: 1,
}),
});
let status: Status;
do {
status = (await call<{ status: Status }>(run.status_url)).status;
console.log("status:", status);
if (["COMPLETED", "FAILED", "CANCELLED"].includes(status)) break;
await new Promise((r) => setTimeout(r, 15_000));
} while (true);
const result = await call<Result>(run.response_url);
console.log("total credits:", result.total_credits);
for (const v of result.videos) {
const name = `video-v${v.variant}-i${v.iteration}.mp4`;
const res = await fetch(v.url); // presigned: no auth header
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
await writeFile(name, Buffer.from(await res.arrayBuffer()));
console.log("saved", name, v.final ? "(final)" : "");
}
Node.js
// Node 18+ (built-in fetch / FormData / Blob). Save as run.mjs: HP_KEY=hp_… node run.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { basename } from "node:path";
const API = "https://api.happypony.io/api/v1/agent/runs";
const AUTH = { Authorization: `Bearer ${process.env.HP_KEY}` };
async function call(url, init = {}) {
const res = await fetch(url, { ...init, headers: { ...AUTH, ...init.headers } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.json();
}
// Optional: upload a local file, get a presigned URL (valid 2 h) for reference_image_urls.
async function upload(path) {
const form = new FormData();
form.append("file", new Blob([readFileSync(path)]), basename(path));
return (await call(`${API}/uploads`, { method: "POST", body: form })).url;
}
const run = await call(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: "The two girls have a bar fight",
reference_image_urls: [
"https://resource.pipelet.net/images/test/selfie3.png",
"https://resource.pipelet.net/images/test/selfie5.png",
],
variants: 2,
feedback_loops: 1,
}),
});
let status;
while (true) {
({ status } = await call(run.status_url));
console.log("status:", status);
if (["COMPLETED", "FAILED", "CANCELLED"].includes(status)) break;
await new Promise((r) => setTimeout(r, 15_000));
}
const result = await call(run.response_url);
console.log("total credits:", result.total_credits);
for (const v of result.videos) {
const name = `video-v${v.variant}-i${v.iteration}.mp4`;
const res = await fetch(v.url); // presigned: no auth header
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
writeFileSync(name, Buffer.from(await res.arrayBuffer()));
console.log("saved", name, v.final ? "(final)" : "");
}
Upload a file
POST /api/v1/agent/runs/uploads (multipart/form-data)
For media that isn't at a public URL: upload it first, then pass the returned
url in any *_url field of Start a run —
reference_image_urls, reference_video_urls, reference_audio_urls,
first_frame_url or last_frame_url. Upload one file per request.
| Form field | Required | Notes |
|---|---|---|
file |
yes | The image, video or audio file. |
kind |
no | image, video or audio. Inferred from the file's content type (or its extension, when the type is application/octet-stream) if left out. |
curl -X POST https://api.happypony.io/api/v1/agent/runs/uploads \
-H "Authorization: Bearer $HP_KEY" \
-F file=@selfie.png
{ "url": "https://…signed…", "kind": "image", "expires_in": 7200 }
The response is 201.
urlis a presigned link to your private copy, valid forexpires_inseconds (2 hours). Start the run before it expires; once the run is created the file is attached to it and the link no longer matters.- Accepted types: images
jpg/png/webp, videomp4/mov/webm/mkv, audiowav/mp3/m4a/aac/ogg/flac. - Size caps: image 20 MB, audio 30 MB, video 200 MB.
- Uploading is free; credits are only spent on renders.
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
No multipart file field. |
| 400 | invalid_media |
code is bad_type (unsupported type, or not the kind you named) or too_large; field is file. |
| 401 | unauthorized |
Missing, wrong or revoked API key. |
| 502 | media_failed |
The file could not be stored. Retry. |
Two local images into one run:
up() { curl -s -X POST https://api.happypony.io/api/v1/agent/runs/uploads \
-H "Authorization: Bearer $HP_KEY" -F "file=@$1" | jq -r .url; }
A=$(up selfie3.png); B=$(up selfie4.png)
curl -X POST https://api.happypony.io/api/v1/agent/runs \
-H "Authorization: Bearer $HP_KEY" -H "Content-Type: application/json" \
-d "{\"prompt\": \"The two girls have a bar fight\", \"reference_image_urls\": [\"$A\", \"$B\"], \"variants\": 2, \"feedback_loops\": 1}"
Start a run
POST /api/v1/agent/runs
curl -X POST https://api.happypony.io/api/v1/agent/runs \
-H "Authorization: Bearer $HP_KEY" -H "Content-Type: application/json" \
-d '{
"prompt": "A 15 second ad for this trail shoe: a runner at dawn, energetic, ends on the logo.",
"reference_image_urls": ["https://cdn.example.com/shoe-front.png", "https://cdn.example.com/shoe-side.png"],
"reference_audio_urls": ["https://cdn.example.com/voice.mp3"],
"hints": { "duration": 15, "aspect_ratio": "9:16", "short_edge": 768, "tier": "quality", "language": "en" },
"variants": 2,
"feedback_loops": 1
}'
{
"request_id": "6f1c…",
"status": "IN_QUEUE",
"status_url": "https://api.happypony.io/api/v1/agent/runs/6f1c…/status",
"response_url": "https://api.happypony.io/api/v1/agent/runs/6f1c…",
"cancel_url": "https://api.happypony.io/api/v1/agent/runs/6f1c…/cancel"
}
The response is 202. Unknown fields are rejected (400), so a typo never
silently changes what is rendered.
| Field | Type | Notes |
|---|---|---|
prompt |
string, required | Your instructions, up to 8000 characters. |
reference_image_urls |
string[] ≤ 8 | Subjects, products, style. More than 4 are collated into reference sheets by the agent. |
reference_video_urls |
string[] ≤ 1 | Motion / identity reference. The render model takes one. |
reference_audio_urls |
string[] ≤ 2 | Voice / sound references. |
first_frame_url, last_frame_url |
string | Pin the opening / closing frame. |
hints.duration |
int 5–30 | Seconds. Fixed, unless the agent may adjust the length (below); then it is the preferred length. Must fit the canvas (resolution, aspect_ratio). |
hints.adjust_duration |
bool | Whether the agent may pick the length itself. Default true when duration_min or duration_max is given, otherwise false. |
hints.duration_min, hints.duration_max |
int 5–30 | The range the agent picks the length from. Default 5 and the longest the canvas allows. |
hints.aspect_ratio |
auto 21:9 16:9 4:3 1:1 3:4 9:16 |
|
hints.resolution |
480p 768p 1080p |
The video's short edge. 1080p is upscaled from the native render. |
hints.short_edge |
480 768 1080 |
The same setting in pixels. Send resolution or short_edge, not both. |
hints.tier |
turbo | quality |
Default quality. Every render in the run uses this tier; the agent never switches it. |
hints.language |
string | Language of speech and on-screen text. |
variants |
int 1–4, default 1 | Independent takes on the brief, each its own agent. |
feedback_loops |
int 0–3, default 0 | How many times a finished render may be reviewed and redone. |
A hint you give is binding: the agent cannot override it. Anything you leave out, the agent chooses.
How long the videos are:
| You send | Each video is |
|---|---|
| nothing | the agent's choice, 5–15 s |
duration: 10 |
exactly 10 s |
duration_min: 8, duration_max: 15 |
whatever length the content needs, within 8–15 s |
duration: 10, duration_min: 8, duration_max: 15 |
within 8–15 s, 10 s preferred |
duration: 10, adjust_duration: true |
anywhere from 5 s to the longest the resolution and aspect ratio allow, 10 s preferred |
Setting duration_min or duration_max lets the agent adjust the length by
itself; sending them with adjust_duration: false is a 400.
With a range, the agent counts the shots, actions and spoken words in your prompt and picks the shortest length that tells it without rushing; the server keeps every render inside the range. The range's upper end is capped at what the canvas allows (15 s at 768p 16:9, 30 s at 480p). The credit pre-check prices a range at its upper end.
Media URLs
Files you uploaded are passed the same way, by their URL.
Each URL is downloaded when the run is created and stored as a private
attachment, so a bad URL fails the request (400 invalid_media, naming the
field) instead of a run later. The rules:
https://only, default port, no credentials in the URL.- The host must resolve to public addresses only (loopback, private, link-local, carrier-NAT and multicast ranges are refused, including after redirects). Up to 3 redirects, each re-checked.
- Content type must match the field: images
jpg/png/webp, videomp4/mov/webm/mkv, audiowav/mp3/m4a/aac/ogg/flac.application/octet-streamis accepted when the file extension says so. - Size caps: image 20 MB, audio 30 MB, video 200 MB. Timeouts: 30 s / 45 s / 120 s.
Credits and limits
- Quote and pre-check. The run's worst case is
variants × (1 + feedback_loops) × the price of one video, where one video is priced from your hints; without hints, atquality, 768 short edge and 15 s. If your balance is below it, the request is402:{"error":"insufficient_credits","required":600,"available":420}. The agent is held to that per-video price: it will not propose a dearer video. Givehints.duration/resolutionif you want longer or larger videos budgeted for. A duration range is priced at its upper end. - What is actually charged is only the renders that were submitted, at the
moment each is submitted. A render that fails is refunded automatically. A
run that stops early (satisfied review, cancellation, failure) costs less than
the quote.
credits_used/total_creditsreport the actual amount. - Concurrency. At most 3 active (
IN_QUEUE/IN_PROGRESS) runs per user; the fourth is429 too_many_runs.
Poll for status
GET /api/v1/agent/runs/:id/status
{
"request_id": "6f1c…",
"status": "IN_PROGRESS",
"phase": "rendering",
"created_at": "2026-09-28T10:00:00.000Z",
"started_at": "2026-09-28T10:00:03.000Z",
"completed_at": null,
"variants": [
{ "variant": 1, "state": "running", "phase": "rendering", "iteration": 1, "max_iterations": 2,
"renders_finished": 0, "render_status": "pending", "queue_position": 3, "eta_seconds": 240 },
{ "variant": 2, "state": "running", "phase": "analysing", "iteration": 0, "max_iterations": 2, "renders_finished": 0 }
],
"credits_used": 60,
"quoted_credits": 240,
"queue_position": 3,
"eta_seconds": 240,
"status_url": "…", "response_url": "…", "cancel_url": "…"
}
status uses the batch queue's names:
| Status | Meaning |
|---|---|
IN_QUEUE |
Accepted, not yet picked up by the agent runner. |
IN_PROGRESS |
The agent is working (see phase). |
COMPLETED |
Every variant that could finish has a final video. A variant that failed is listed in variant_errors; the run is still COMPLETED if at least one variant has a video. |
FAILED |
No variant produced a video. error says why. |
CANCELLED |
Cancelled by you. |
phase (run level: the most advanced running variant; null once finished):
| Phase | Meaning |
|---|---|
analysing |
The agent reads your material and writes a plan. |
planning |
The plan is being priced and submitted. |
rendering |
A render is queued or running. queue_position and eta_seconds come from the render queue. |
reviewing |
Frames of a finished render are being checked against your instructions. |
revising |
The agent is rewriting the plan after a review (or a content-check refusal). |
Per variant, iteration counts renders started and max_iterations is
feedback_loops + 1. state is running, done or failed.
Polling every 10–30 s is plenty; there are no webhooks.
Get the result
GET /api/v1/agent/runs/:id
{
"request_id": "6f1c…",
"status": "COMPLETED",
"videos": [
{ "variant": 1, "iteration": 1, "final": false, "url": "https://…signed…", "activity_id": 812,
"credits": 120,
"parameters": { "title": "Trail shoe dawn run", "mode": "from references", "tier": "quality", "duration": 15,
"short_edge": 768, "aspect_ratio": "9:16", "inputs": "reference images img1, img2", "seed": 1804289383,
"brief": "A runner laces the shoe at dawn, then…" },
"prompt_used": "…",
"review": { "satisfied": false, "summary": "The shoe is not visible in the last shot.", "issues": ["logo missing"], "revision": "end on a close-up of the logo" } },
{ "variant": 1, "iteration": 2, "final": true, "url": "https://…signed…", "activity_id": 815,
"credits": 120, "parameters": { "…": "…" }, "prompt_used": "…" }
],
"total_credits": 240,
"variant_errors": [{ "variant": 2, "error": "the content check refused every plan (minor): …" }],
"log": [
{ "at": "2026-09-28T10:00:03.100Z", "kind": "run", "message": "Run started", "data": { "variants": 2, "feedback_loops": 1 } },
{ "at": "2026-09-28T10:00:20.400Z", "variant": 1, "kind": "plan", "message": "Proposed \"Trail shoe dawn run\" (120 credits)", "data": { "credits": 120, "settings": { }, "prompt": "…" } }
]
}
videoshas one entry per finished render.final: truemarks the render the run delivers for that variant: the one that satisfied the review, or the last one when the loops ran out (or when a later render failed). Usefinal, not the order.urlis a presigned download link, valid for 2 hours. Fetch the run again for a fresh one.creditsis what that render cost;total_creditsis the run's total after refunds.parametersare the settings the render was submitted with, as the agent chose them (within yourhints):mode(text to video,from framesorfrom references),tier,duration(seconds),short_edge(pixels) andresolution(e.g.480p),aspect_ratio,inputs(which of your files it used, by label:img1is your first image),seed, andbrief, the agent's plan in plain words.prompt_usedis the full prompt the renderer received.reviewis present when that render was reviewed. The review sees sampled frames, not the audio.logis every step in order, each{at, variant?, iteration?, kind, message, data?}. Kinds:run,phase,input,agent,plan,job,review,variant,cancel,error. It never contains signed URLs, the agent's system prompt or its private reasoning.
While a run is in progress the same endpoint returns the videos finished so far.
Runs also appear in the video agent's chat history on happypony.io, marked API: one read-only chat per run, with your instructions and each video's plan, render and review in order.
Cancel
PUT /api/v1/agent/runs/:id/cancel
{ "request_id": "6f1c…", "status": "CANCELLED", "renders_cancelled": 1, "renders_still_running": 0 }
The run stops within seconds. Renders still waiting in the render queue are
cancelled there and refunded. A render a worker has already started cannot be
stopped: it finishes, its credits stay spent, and it does not appear in the
run's videos. Cancelling a run that already finished is 409 not_cancellable.
Errors
| Status | error |
When |
|---|---|---|
| 400 | invalid_request |
Bad JSON, unknown field, out-of-range value. details lists every problem. |
| 400 | invalid_media |
A URL was refused or could not be downloaded. field names it, code is blocked, invalid_url, unreachable, too_large, bad_type or http_error. |
| 401 | unauthorized |
Missing, wrong or revoked API key. |
| 402 | insufficient_credits |
Balance below the worst case. Has required and available. |
| 404 | not_found |
No such run, or it is not yours. |
| 409 | not_cancellable |
The run is already finished. |
| 429 | too_many_runs |
Three runs are already active. |
| 502 | media_failed |
The media could not be stored. Retry. |
How a run works
- The request is validated, quoted and its media stored. Each variant gets its own private agent conversation (never shown in the chat history) starting from your instructions.
- A runner process claims the run (with a lease it keeps renewing, so a restart or another replica picks it up where it stopped, without submitting a render twice), and drives every variant in parallel: the agent analyses the material and proposes a video; the proposal is confirmed automatically at the quoted price and submitted to the render queue.
- When a render finishes and loops remain, sampled frames plus your instructions and the prompt go to the vision model for a structured critique. If it is satisfied the variant is done; otherwise the agent revises and proposes again.
- A content-check refusal is fed back to the agent, which revises the plan (up to 3 times per variant). Any other hard failure ends that variant only.