Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.agentium.in/llms.txt

Use this file to discover all available pages before exploring further.

Async HandleId Pattern

The problem

Some tool calls take 5–60 seconds: video rendering, batch jobs, slow third-party APIs, large file downloads. If the tool blocks synchronously, the agent loop blocks too. That’s bad:
  • The user sees a frozen UI.
  • Streaming providers may close the connection on timeout.
  • The agent can’t do anything else while it waits — it can’t even tell the user “this will take a minute”.
The fix is to return a handle immediately, run the real work in the background, and let the agent poll for the result later.

defineAsyncTool

import { defineAsyncTool } from "@agentium/core";
import { z } from "zod";

const renderVideo = defineAsyncTool({
  name: "renderVideo",
  description: "Render a 5-second video clip from a script.",
  parameters: z.object({ script: z.string() }),
  ttlSeconds: 600,              // default 600 — drop result after 10 min
  execute: async ({ script }) => {
    // The long-running work. Runs in the background; the LLM doesn't wait.
    const url = await videoServiceClient.render(script);  // takes ~30s
    return url;
  },
});
When the LLM calls renderVideo({ script: "..." }), the tool returns immediately:
{
  "handle": "ah:550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "note": "Call pollResult with this handle (and optionally waitMs to wait until ready) to retrieve the result."
}
Meanwhile, the real execute() is running in a fire-and-forget background promise. When it finishes, the result is cached on RunContext.sessionState["__asyncHandles"] keyed by the handle.

Config

FieldTypeDefaultMeaning
namestringrequiredTool name
descriptionstringrequiredThe framework auto-appends "[Async] Returns a handle..." to help the model understand the contract
parametersz.ZodObjectrequiredZod schema (same as defineTool)
execute(args, ctx) => Promise<string | ToolResult>requiredThe slow work. Runs in the background.
ttlSecondsnumber600After this many seconds the cached result is dropped. Subsequent pollResult calls return status: "expired".

createPollResultTool()

Add this once to your agent’s tool list. The LLM calls it to retrieve results.
import { Agent, createPollResultTool, openai } from "@agentium/core";

const agent = new Agent({
  name: "video-bot",
  model: openai("gpt-4o"),
  tools: [renderVideo, createPollResultTool()],
  instructions: `
    When the user wants a video, call renderVideo (returns a handle).
    Then call pollResult(handle, waitMs: 30000) to retrieve the URL.
  `,
});

pollResult parameters

{
  handle: string;      // "ah:..." from a defineAsyncTool call
  waitMs?: number;     // optional, max 30000 — block up to this many ms for completion
}

pollResult return values

statusWhenOther fields
"pending"Background work still in flighthandle
"done"Work completed successfullyhandle, result: <execute return value>
"error"Background work threwhandle, error: <message>
"expired"Result was older than ttlSecondshandle
"not-found"Handle doesn’t exist (typo, wrong session)handle
Result is always JSON-stringified for the LLM to parse.

waitMs semantics

pollResult(handle, waitMs: 10000) polls every 100ms for up to 10 seconds. If the result arrives mid-poll, it returns immediately with status: "done". If the deadline expires with the work still pending, it returns status: "pending" (the LLM should call again). Capped at 30000ms to prevent the LLM from blocking forever.

Complete example

import { Agent, createPollResultTool, defineAsyncTool, openai } from "@agentium/core";
import { z } from "zod";

const renderVideo = defineAsyncTool({
  name: "renderVideo",
  description: "Render a video clip from a script.",
  parameters: z.object({ script: z.string() }),
  ttlSeconds: 600,
  execute: async ({ script }) => {
    await new Promise((r) => setTimeout(r, 7_000));
    return `https://cdn.example.com/videos/${Date.now()}.mp4`;
  },
});

const agent = new Agent({
  name: "video-bot",
  model: openai("gpt-4o"),
  tools: [renderVideo, createPollResultTool()],
  instructions:
    "When the user requests a video, call renderVideo to start rendering, " +
    "then call pollResult with the returned handle and waitMs: 10000 to retrieve the URL.",
});

const result = await agent.run("Make me a 5-second clip of a sunset over the mountains.");
console.log(result.text); // "Here's your sunset clip: https://cdn.example.com/videos/..."

Internal storage

Handles live on RunContext.sessionState["__asyncHandles"] as a Map<string, HandleEntry>. Each entry tracks:
interface HandleEntry {
  status: "pending" | "resolved" | "rejected";
  value?: unknown;        // resolved value
  error?: string;         // rejection message
  startedAt: number;
  ttlMs: number;
}
The map is cleared with RunContext. For multi-run handle persistence, serialize sessionState between runs via your session manager.

Composition with other patterns

Async + Memory Pointers

When the async result is itself huge:
const downloadDataset = defineAsyncTool({
  name: "downloadDataset",
  parameters: z.object({ name: z.string() }),
  execute: async ({ name }) => {
    return await fetch(`https://datasets.example.com/${name}.csv`).then((r) => r.text());
  },
});
When pollResult returns status: "done", result: <huge text>, the auto-pointer converter wraps that result in an art: pointer. The agent then calls getArtifact(pointer) if it needs the bytes.

Async + BullMQ background queue

For work that needs to survive process restart, push the real execution to BullMQ via @agentium/queue and store the BullMQ job ID as the handle:
const reportTool = defineAsyncTool({
  name: "generateReport",
  parameters: z.object({ quarter: z.string() }),
  execute: async ({ quarter }) => {
    const job = await reportQueue.add("render", { quarter });
    return await job.waitUntilFinished(reportEvents);
  },
});
The handle now indirectly references a durable BullMQ job, so even if the agent process restarts, the work continues.

When to use

  • API calls > 5 seconds
  • Video / audio / image generation
  • Large data downloads
  • Anything that benefits from the LLM doing something else while waiting

When NOT to use

  • Sub-second tools — the handle overhead isn’t worth it
  • Tools whose result the LLM needs to reason about immediately
  • Tools called inside a tight workflow loop where everything is sequential anyway

See also