Skip to main content
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

  1. EnqueuePOST /api/v1/research or /api/v1/video returns 202 with { id, job_id, status: "queued", poll_url }.
  2. PollGET /api/v1/jobs/{job_id} returns the current status: queuedactivecompleted (or failed).
  3. 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.