Some Theo capabilities are too slow to return inline. research (plan → multi-source web search → synthesis) and video (model generation) run as background jobs: you enqueue them, get a job_id back immediately, then poll until they finish.
Do not send mode: "research" or mode: "video" to /completions or theo.stream(). Those run the work inline, which blows past the request timeout. The SDK throws a TheoUsageError immediately if you try. Use the dedicated methods below.
Lifecycle
- Enqueue —
POST /api/v1/research or /api/v1/video returns 202 with { id, job_id, status: "queued", poll_url }.
- Poll —
GET /api/v1/jobs/{job_id} returns the current status: queued → active → completed (or failed).
- Read the result — when
status === "completed", result holds the typed payload.
SDK usage
waitForJob() does the polling loop for you, with an optional onProgress callback and an AbortSignal.
import { Theo, type ResearchJobResult } from "@hitheo/sdk";
const theo = new Theo({ apiKey: process.env.THEO_API_KEY! });
const job = await theo.research({
prompt: "Compare React, Vue, and Svelte for enterprise apps in 2026",
depth: "advanced",
max_sources: 10,
});
const final = await theo.waitForJob<ResearchJobResult>(job.job_id, {
intervalMs: 3000, // poll cadence (default 2s)
maxWaitMs: 180_000, // give up after 3 min (default 5 min)
onProgress: (s) => console.log(`${s.status} ${s.progress}%`),
signal: AbortSignal.timeout(180_000), // optional hard cancel
});
if (final.status === "completed") {
console.log(final.result?.report);
console.log(`${final.result?.sourceCount} sources`);
}
Video works the same way with theo.video() + waitForJob<VideoJobResult>().
Polling cadence & limits
- Default poll interval is 2s;
waitForJob gives up after 5 minutes by default (maxWaitMs) and throws a TheoTimeoutError. Raise maxWaitMs for long video renders.
- Don’t poll faster than ~1s — it wastes rate-limit budget without finishing the job any sooner.
Cancellation
Pass an AbortSignal. When it fires, waitForJob stops polling and rejects with a TheoCancelledError (it does not cancel the server-side job — it just stops your client from waiting).
const controller = new AbortController();
// e.g. user navigates away
document.addEventListener("stop", () => controller.abort());
await theo.waitForJob(job.job_id, { signal: controller.signal });
Result schemas
result is typed via the generic on job<T>() / waitForJob<T>(). The shapes mirror the REST payload (Get Job Status):
ResearchJobResult — { report, sources: { title, url }[], queries, sourceCount }
VideoJobResult — { videoUrl, model, engine, durationMs }
ImageJobResult — { imageUrl, model, engine }
DocumentJobResult — { title, format, downloadUrl, sizeBytes }
Any model / engine fields are Theo-branded — raw upstream identifiers never appear.
Cost & usage
Job results do not yet carry a per-job cost_cents field. To attribute spend for research/video jobs, read the aggregated Usage report (theo.usage()), which includes a by_mode breakdown covering research and video.
Failures
When status === "failed", result is null and error holds the reason string. The enqueue call itself can also return 503 queue_unavailable if the job queue is temporarily down — retry shortly.