Long-running operations
How to handle ops longer than the 30s ctx.http default — federal cap, background tasks, chat injection
Some operations legitimately take longer than the 30-second default ctx.http timeout — AI generation, multi-stage retrieval, large file transfers, third-party batch APIs. Imperal Cloud gives you a tiered set of SDK surfaces, shipped together in v4.2.12 with a declarative-sugar form added in v4.2.13.
This page is the mental model. For copy-paste patterns, see Long-running AI calls.
The surfaces
| Surface | When to reach for it | Federal cap |
|---|---|---|
ctx.http(..., timeout=N) | A single HTTP call legitimately needs 30–180 seconds. | 180s (I-LONGRUN-HTTP-CAP-180S) |
@chat.function(background=True, long_running=False) (sugar, v4.2.13+) | The whole handler body is the long work. SDK auto-wraps in ctx.background_task(); you write one handler, no inner coro. | 180s default; 1800s with long_running=True |
ctx.background_task(coro, long_running=False) (explicit, v4.2.12+) | You need a custom acknowledgement summary, conditional dispatch (sync vs background at runtime), or mixed sync + background in one handler. | 180s default; 1800s with long_running=True |
ctx.deliver_chat_message(text) | You want to inject a bot message into the user's chat outside a normal handler reply (OAuth callback ack, mid-stage announcement, one-off proactive note). | 64KB body |
Decision tree
Will your op finish in ≤ 30s?
├── yes → use ctx.http with the default 30s timeout. Done.
└── no
├── Single HTTP call that needs 30–180s?
│ └── yes → ctx.http.post(url, ..., timeout=120). Chat blocks until done.
└── Op > 180s OR you want chat to stay free?
├── Entire handler body is the long work + auto ack is fine
│ → @chat.function(background=True, long_running=...) ← SUGAR
└── Need custom ack summary or conditional dispatch?
→ handler returns ack synchronously, calls
ctx.background_task(coro, long_running=...) ← EXPLICITWhat changes for your user
Pattern A — ctx.http(timeout=N) (30–180s)
The user sees the chat "thinking" indicator for the duration of the call. Once the handler returns, the final ActionResult lands as a single bot turn. No additional setup.
Pattern B (sugar) — @chat.function(background=True) (v4.2.13+)
Add background=True to the decorator and write a single handler body. The SDK auto-wraps the call in ctx.background_task() under the hood. The user sees two bot turns:
- Immediate (auto-generated) — "Started 'your_function_name' in background — the result will be sent to chat when it finishes." Chat unblocks instantly.
- Later — when your handler returns, the platform injects a fresh bot turn rendering the returned
ActionResult. The user may have continued chatting about other things in between; the result lands in-place anyway.
@chat.function(
"refine_output",
description="Refine the given text via AI completion.",
action_type="write",
event="text_refined",
background=True,
long_running=False,
)
async def refine_output(ctx, params: RefineParams) -> ActionResult:
# Body runs detached. The ActionResult below is what the user sees later.
await ctx.progress(50, "Generating with AI")
resp = await ctx.http.post(api_url, json={...}, timeout=120)
return ActionResult.success(
summary="Refined output ready!",
data={"text": resp.body["text"]},
)Pattern C (explicit) — ctx.background_task(coro) (v4.2.12+)
Choose this over the sugar form when:
- You need a custom acknowledgement summary in turn 1 instead of the auto-generated one.
- Your handler chooses synchronously vs. background at runtime based on parameters.
- Your handler does some sync work first, then detaches the slow tail.
See the recipe for both Pattern B (sugar) and Pattern C (explicit) full code examples.
In both patterns the user can close the browser between turn 1 and turn 2; the result lands in chat history and shows up on next open.
Federal contract — what the platform guarantees
Five LONGRUN invariants enforced for you
I-LONGRUN-HTTP-CAP-180S—ctx.http(timeout=N)capped at 180 seconds. Larger raisesValueErrorpointing back here.I-LONGRUN-BG-CORO-RETURNS-ACTIONRESULT— the coroutine you pass toctx.background_task()MUST returnActionResult. Returning anything else triggers a critical audit row and delivers a fallback error message to the user.I-LONGRUN-BG-USER-SCOPED— every background task is bound to(your_ext, user)at creation. Cross-user cancel/status access returns 403.I-LONGRUN-CHAT-INJECT-USER-SCOPED—ctx.deliver_chat_message()and the auto-delivery from background tasks both land in the message's owner user only. Cross-user inject returns 403.I-LONGRUN-CHAT-INJECT-AUDIT-EVERY— every chat injection writes an audit row at the chokepoint. Not an extension policy — a platform-enforced contract.
Progress emissions
For background tasks longer than a minute, emit ctx.progress(percent, message) at every coarse milestone. Two reasons:
- User feedback — Imperal renders a progress indicator in chat for the running task; users see what stage you're in.
- Liveness — the platform uses progress emissions as heartbeats. Long silence may cause the task to be reclaimed.
await ctx.progress(15, "Fetching context")
# ... work ...
await ctx.progress(50, "Generating with AI")
# ... work ...
await ctx.progress(90, "Saving")If the user cancels the task, the next ctx.progress() call raises TaskCancelled. Let it propagate — the platform handles the cancellation delivery to chat.
When NOT to use a background task
- Reads under 1 second. The orchestration overhead isn't worth it. Just return
ActionResultsynchronously. - CPU-bound loops with no
await. The platform's liveness check assumes the coroutine yields. Wrap heavy CPU work withawait asyncio.sleep(0)between iterations, or move it to an external service called viactx.http. - Streaming partial output to chat.
ctx.background_taskdelivers a single final result. For per-token streaming, build streaming directly into your handler instead.