---
title: "task"
description: "The task namespace — the engine's Luau API reference for task."
section: "API Reference"
slug: "api-task"
canonical: "https://origozero.ai/docs/api-task"
updated: "2026-09-04T19:42:51.262141463+00:00"
tags: ["api", "reference"]
---

# task

The `task` namespace — 21 functions.

## task/active {#task-active}

```lua
task.active() -> number
```

Get number of active (running + waiting + deferred) tasks.

**Returns** `number` — Count of active tasks

## task/await {#task-await}

```lua
task.await(promise_id) -> any
```

Yield until a promise is resolved. Used with mcp_call and other promise-returning APIs.

**Parameters**

- `promise_id` `string` — Promise identifier returned by a promise-returning API.

**Returns** `any` — The resolved value.

## task/background {#task-background}

```lua
task.background() -> boolean
```

Declare that the calling task belongs in the background. A caller driving it inline — `use_tool`, `bash`, `execute` — hands back the task's watchable handle on its next poll instead of waiting out an inline budget the work will outlast. Call it as soon as the operation knows its own scale, before the long stretch begins; the decision can be per-call, so a small scope stays inline and a large one is handed back. Returns false outside any scheduler task, where there is no handle to hand back. Completion still arrives the usual way: poll task.status(handle), read the watch file, or wait for the operation's own notice.

**Returns** `boolean` — True when the calling task was marked; false outside any task

See also: [`task/current`](api-task) · [`task/status`](api-task)

## task/batched {#task-batched}

```lua
task.batched(count, fn, batch_size?, on_complete?) -> number
```

Run `fn(i)` for `i = 1..count`, yielding to the next frame every `batch_size` iterations (default 100). Spawns a coroutine and returns its task handle immediately. Use for bulk spawn loops or mass mutations that would otherwise block the VM long enough to trip the watchdog.

**Parameters**

- `count` `number` — Total number of iterations.
- `fn` `function` — Function called as fn(i) for each iteration.
- `batch_size` `number` _(optional)_ — Iterations per frame before yielding (default 100).
- `on_complete` `function` _(optional)_ — Optional callback invoked after the final iteration.

**Returns** `number` — Task handle (the spawned coroutine).

## task/callerInfo {#task-callerinfo}

```lua
task.callerInfo(level?) -> string?
```

Return the formatted `"<source>:<line>"` of a Luau stack frame, or nil if the frame doesn't exist or has no source. `level` defaults to 1 (the immediate Lua caller); 2 skips one frame above, and so on. Used by `task.loop` to tag iteration-error warnings with the spawn site, and useful for any custom error-reporting wrapper since the stock `debug.info` is gated out of user scope. See gh#3416 / gh#3248.

**Parameters**

- `level` `number?` — Stack level — 1 (default) = immediate Lua caller, 2 = caller's caller, etc.

**Returns** `string?` — "source:line" string, or nil when unavailable

## task/cancel {#task-cancel}

```lua
task.cancel(handle)
```

Cancel a running or waiting task. The coroutine will not resume. No-op on finished tasks.

**Parameters**

- `handle` `number` — Task handle from spawn/defer/delay

## task/collect {#task-collect}

```lua
task.collect(handle?) -> boolean
```

Free a finished task handle (or all finished handles if no argument). Returns true if any were collected. Finished handles are auto-collected after ~5 seconds.

**Parameters**

- `handle` `number?` — Optional task handle to collect; omit to collect all

**Returns** `boolean` — Whether any handles were collected

## task/current {#task-current}

```lua
task.current() -> number?
```

The handle of the task the calling code runs in, or nil outside any scheduler task. Pair it with task.status to tell whether the owner of a long-running operation is still being advanced: a cancelled task stops mid-flight without unwinding, so state it claimed (a lock, an in-flight flag, a progress record) outlives it unless someone can ask about the owner. Record task.current() when you take such a claim, and let the next caller reap it when task.status(owner) reports a terminal state. Scoped to the calling VM.

**Returns** `number?` — Handle of the calling task, or nil when not running inside one

## task/defer {#task-defer}

```lua
task.defer(fn, ...args) -> number
```

Schedule fn to run at end of current frame. Returns a task handle that can be used with task.status/task.cancel.

**Parameters**

- `fn` `function` — Function to run at end of frame

**Returns** `number` — Task handle

## task/delay {#task-delay}

```lua
task.delay(seconds, fn, ...args) -> number
```

Schedule fn to run after `seconds` of real time. Returns a task handle.

**Parameters**

- `seconds` `number` — Seconds before fn runs.
- `fn` `function` — Function to run after the delay.

**Returns** `number` — Task handle for cancel/status.

## task/inflight {#task-inflight}

```lua
task.inflight(label) -> number
```

Number of scheduler tasks with the given label that have been dispatched and not yet reached a terminal state (finished or cancelled). Component lifecycle hooks dispatch under their hook name — task.inflight("awake") == 0 means every awake() coroutine has settled. The join primitive for "loaded means loaded": scene-load completion polls it so 'done' includes the detached lifecycle work the spawn dispatched, not merely the entity rows.

**Parameters**

- `label` `string` — Task label to count (e.g. "awake")

**Returns** `number` — Count of in-flight tasks carrying that label

## task/loop {#task-loop}

```lua
task.loop(fn, dt?) -> number
```

Spawn a self-healing per-frame loop. Each iteration runs `fn(delta)` where `delta` is the seconds elapsed since the previous iteration, so `task.loop(function(delta) pos += speed * delta end)` moves by real time; a no-argument `function() end` still works. An uncaught error inside `fn` is caught, surfaced to `task.onError` (if registered), logged via `log.warn`, and the loop continues on the next iteration. Use this for the common scene pattern of one long-lived coroutine that drives input/sim/UI per frame; a bad nil-deref on one entity no longer kills the entire loop. See gh#3248.

**Parameters**

- `fn` `function` — Body to run every iteration, called as `fn(delta)` with the seconds elapsed since the previous iteration. Errors are caught — they do not kill the loop.
- `dt` `number` _(optional)_ — Seconds to wait between iterations (default 0 = next frame). This is the loop interval, distinct from the per-iteration `delta` passed to `fn`.

**Returns** `number` — Task handle (cancel via task.cancel to stop the loop).

## task/onError {#task-onerror}

```lua
task.onError(handler: ((err: string, source: string?, handle: number) -> ())?)
```

Register a per-VM error handler invoked when a coroutine spawned via task.spawn / task.defer / task.delay errors. The handler receives (err_message, source_name, handle) and is itself called under pcall — its own errors are caught and logged, never re-raised. Passing nil clears the handler. Use this to surface 'silent freeze' coroutine deaths to scene-side code (e.g. show a SCRIPT ERROR overlay, restart the loop with backoff, escalate to telemetry). See gh#3248.

**Parameters**

- `handler` `function | nil` — Function called as (err, source, handle) on coroutine error, or nil to clear

## task/result {#task-result}

```lua
task.result(handle) -> any?
```

Get a FINISHED task's return value, decoded from its lossless JSON capture. Raises when the task is not finished — still running/waiting/deferred, failed, cancelled, or an unknown handle — naming the state, so a nil return means exactly one thing: the task finished and returned nil. Poll task.status(handle) until it reports 'finished' (or wait on the watch file a promoted call gives you), then read the result.

**Parameters**

- `handle` `number` — Task handle from spawn/defer/delay

**Returns** `any?` — The finished task's return value; nil only when it returned nil. Raises otherwise.

## task/spawn {#task-spawn}

```lua
task.spawn(fn, ...args) -> number
```

Spawn a new coroutine that runs fn with the given args. Returns a numeric task handle (not a thread) that can be passed to task.cancel/task.status.

**Parameters**

- `fn` `function` — Function to run in a new coroutine

**Returns** `number` — Task handle (numeric ID) for cancel/status

## task/spawnSystem {#task-spawnsystem}

```lua
task.spawnSystem(fn, ...args) -> number
```

Like task.spawn, but the coroutine is forced system-owned: it is never gameplay-owned, so it keeps running while the editor pauses gameplay — even when started from a component's awake()/onEnable(). Use for system-level loops (input ticking, watchers, editor tooling) that must survive pause regardless of where they were launched. Nested spawns inherit non-ownership normally.

**Parameters**

- `fn` `function` — Function to run in a new system-owned coroutine

**Returns** `number` — Task handle (numeric ID) for cancel/status

## task/status {#task-status}

```lua
task.status(handle) -> string
```

Get task status: 'running' (executing), 'waiting' (task.wait), 'suspended' (await), 'deferred' (queued), 'finished' (completed), 'cancelled', or 'dead' (unknown handle / aged out of terminal history). A finished task that was auto-collected still reports its terminal status from the terminal-history cache (mirrors task.result), so missing the live finished flip doesn't lose the outcome.

**Parameters**

- `handle` `number` — Task handle from spawn/defer/delay

**Returns** `string` — Task status: 'running' | 'waiting' | 'suspended' | 'deferred' | 'finished' | 'cancelled' | 'dead'

## task/wait {#task-wait}

```lua
task.wait(seconds?) -> number
```

Yield the current coroutine. With a duration, resumes after `seconds` of real time. With none — `task.wait()` — resumes on the NEXT frame: `getFrame()` reads exactly one higher on the other side of it, wherever in the frame the wait was armed. Either way it returns the elapsed seconds.

**Parameters**

- `seconds` `number` _(optional)_ — Seconds to wait. Omit (or 0) to wait exactly one frame.

**Returns** `number` — Actual elapsed seconds.

## task/waitDrawnFrames {#task-waitdrawnframes}

```lua
task.waitDrawnFrames(frames?) -> number
```

Yield the current coroutine until the renderer has DRAWN `frames` more frames (default: 1), and ask it to keep drawing for as long as the wait lasts. This is the wait to use before reading anything the renderer produced — a capture, an observation, a readback, a cost — because the engine runs a frame whether or not anything wants a picture of it, and a headless renderer declines the ones nothing is consuming. `task.waitFrames(n)` counts frames the ENGINE ran, which on an idle headless engine is many times the number it drew. `renderer.drawnFrames()` is the count this is deadlined on.

**Parameters**

- `frames` `number` _(optional)_ — Drawn frames to wait for. Default 1.

**Returns** `number` — Frames the renderer drew across the wait.

## task/waitFrames {#task-waitframes}

```lua
task.waitFrames(frames?) -> number
```

Yield the current coroutine until `frames` more frames have run (default: 1). Exactly that many: the deadline is the engine's own frame counter, the one `getFrame()` reads, so `local a = getFrame() task.waitFrames(n)` leaves `getFrame() - a == n` however long those frames took and wherever in the frame the wait was armed. Work that lands a fixed number of frames later is read by counting frames rather than seconds, and one yield covers the whole span, where the same wait spelled as repeated `task.wait()` calls costs a scheduler round-trip per frame. It counts frames the ENGINE ran; frames the renderer submitted is a separate count (`renderer.framePacing().submittedFrames`), lower whenever the renderer declines a frame nothing is consuming.

**Parameters**

- `frames` `number` _(optional)_ — Frames to wait. Default 1 (the next frame).

**Returns** `number` — Frames waited.

## task/wake {#task-wake}

```lua
task.wake(co: thread, ...: any) -> (boolean, string?)
```

Resume a suspended coroutine and keep the scheduler's ownership of what it yields next. Code that captures `coroutine.running()` and resumes it later — a signal, a component event — uses this so the resumed coroutine's `task.wait` still reaches the scheduler and its task keeps reporting status and result. A coroutine the scheduler is not holding parked is resumed directly, and one whose task was cancelled is refused. Returns true when the resume ran, or false and the reason it could not.

**Parameters**

- `co` `thread` — The suspended coroutine to resume
- `...` `any` _(optional)_ — Values the coroutine's yield returns

**Returns** `boolean` — Whether the coroutine was resumed
