Async Wait-Points And Live Results
Pattern
A request cannot stay alive while something out-of-band finishes — a slow third-party call, a webhook-driven job, or a long AI prompt. An async wait-point lets a script start that work, hand the external system a one-time callback URL, and let the platform pick the flow back up when the callback arrives (or a timeout fires) — by resuming the current automation, running another automation, calling a custom-app handler, or pushing the result back to the Cloud Console live.
Use it when the outcome arrives later and from outside the current request. Reach for it instead of looping or sleeping inside one long script.
How It Works
$api->flow->asyncWaitpoint($task, $options):
- Mints a one-time callback URL + token. When it resumes an automation, it also snapshots that automation's variables and trigger arguments so it can continue later.
- Runs your
$task($cb)synchronously — this is your hook to start the background work against$cb['callbackUrl'](fire an async HTTP call, dispatch an AI prompt to that URL, queue an external job). - Returns immediately (fire-and-continue) — unless you explicitly ask it to suspend the current automation.
- Resumes when the callback lands. The external system POSTs to the callback URL (or the timeout elapses), the platform claims the run once (so a duplicate callback can't double-run it), and runs the continuation you chose.
Because suspending throws out of the script, always start the background work inside
$task— code after theasyncWaitpoint(...)call does not run when you suspend.
Choosing A Continuation
| Continuation | What runs on callback | Suspends? |
|---|---|---|
| (none, inside an automation PHP action) | THIS automation, at the action's On async completed endpoint | only with continueMainFlow: false |
continuation: ['automation' => id] | that automation, fresh, with the result as trigger parameters | no |
continuation: ['handler' => 'route/key'] | a custom-app BFF route | no |
continuation: ['notify' => channel] (auto-stamped in the Cloud Console) | nothing — pushes a {asyncRunId, status} ping to a live UI | no |
Suspension is always opt-in: by default the main flow keeps running and the On async
completed endpoint still fires when the callback arrives. Any run can be polled with
$api->flow->getRun($asyncRunId) (read request for the raw callback body, result for
an AI-parsed one). See the Internal API Reference
for the full surface.
Example: Wait For An External Webhook
An automation PHP action starts an e-signature request and pauses until the provider webhooks back, then continues under the action's On async completed endpoint.
// Stash anything you need after the wait — automation variables are snapshotted.
$api->vars->setVariable('contractId', $contractId);
$api->flow->asyncWaitpoint(
function (array $cb) use ($api, $contractId) {
// $cb = ['asyncRunId'=>…, 'callbackUrl'=>…, 'callbackToken'=>…, 'timeoutAt'=>…]
$api->http->fetch('https://esign.example/v1/envelopes', [
'method' => 'POST',
'json' => ['document_id' => $contractId, 'webhook_url' => $cb['callbackUrl']],
]);
},
['timeout' => '+2 days', 'continueMainFlow' => false] // explicitly suspend
);
Step by step:
setVariablerecordscontractId(captured in the snapshot).asyncWaitpointmints the callback URL, snapshots the automation, and records a pending run with a 2-day timeout.$taskfires the HTTP POST, handing the providercallbackUrlas their webhook.- With
continueMainFlow: false, the automation suspends — the request ends; nothing after the call runs. - Later, the provider POSTs to the callback URL. The platform restores the snapshot
(
var('contractId')is back) and runs the actions under On async completed, whereendpoint('requestContent')holds the provider's JSON. The run is marked completed. - If nobody calls back within 2 days, the On async timeout endpoint fires instead.
Drop continueMainFlow: false and the flow continues immediately while the On async
completed endpoint still fires on the callback — use that when the rest of the flow does
not depend on the result.
In The Cloud Console: Live Results
The Cloud Console is a REPL: each run is a separate request, so a normal async call would
finish out-of-band and the result would never reach the open console. The notify
continuation closes that gap. In the console it is auto-stamped with the console's live
channel, so an async call needs no target at all — the result is pushed back to the same
console tab, and an optional then snippet re-runs there with the result.
// In the Cloud Console — no continuation target needed:
$run = $api->flow->asyncWaitpoint(
fn(array $cb) => $api->http->fetch('https://provider.example/render', [
'method' => 'POST', 'json' => ['callback' => $cb['callbackUrl'], 'input' => 'hello'],
]),
[
'timeout' => '+5 minutes',
'continuation' => ['then' => 'return "done: " . json_encode($request);'],
]
);
return "dispatched {$run['asyncRunId']}";
What happens:
- The open console is already subscribed to its live channel.
- The call records a notify run tied to that channel and this tab, fires
$task, and returns theasyncRunIdimmediately (the console never suspends — it is not an automation). - When the provider POSTs back, the platform records the result and pushes a status-only
ping (
{asyncRunId, status}) to the channel — never the body, since the channel is the capability. - The console receives the ping and pulls the result over an authenticated, owner-scoped
path, then runs your
thensnippet with the session's persisted variables rehydrated and$result(AI-parsed),$request(the raw callback body — use this for non-AI waitpoints),$context,$error, and$asyncRunIdin scope. - The output appears as a new entry in the console, live — no reopen, no polling. A timeout
runs the same snippet with
$error = 'timeout'.
For an AI prompt, prefer $api->ai->promptAsync($cfg, ['then' => '…']), which uses the same
console machinery and gives a meaningful $result. See Cloud Console.
AI Prompts, Asynchronously
A model call routinely takes longer than a request should live, so the AI connector runs on
the same async machinery: $api->ai->promptAsync($config, $continuation) dispatches the
prompt and returns an asyncRunId immediately (it never suspends). Choose the continuation
exactly like a wait-point — a custom-app handler, another automation, the Cloud Console, or
a callback URL. For a short prompt that comfortably fits the request budget, use the
synchronous $api->ai->prompt($config) instead.
$config mirrors the Prompt a model action: model
(a model id or a provider::model choice), system, prompt, context_blocks,
attachments, response_format (text | json | json_schema), temperature, params, …
Hand Off From A Custom App, Continue In A Handler
// dispatch route, e.g. POST /app/orders/api/summarize
$runId = $api->ai->promptAsync(
['model' => 'gpt-5-mini', 'prompt' => 'Summarize order ' . $body['orderId'], 'response_format' => 'json'],
['handler' => 'orders/ai-done', 'context' => ['orderId' => $body['orderId']]]
);
$api->sendJson(['runId' => $runId]);
// continuation handler orders/ai-done — runs as the dispatching user, fire-and-continue.
// It receives $result (text/json/usage/finish_reason), $context, $error, $asyncRunId, $request:
if ($error === null) {
$api->events->log('Order summary ready', 'ai', $result['text'], ['orderId' => $context['orderId']]);
}
Run An Automation When The Answer Arrives
$api->ai->promptAsync(
['model' => 'gpt-5-mini', 'prompt' => 'Draft a reply to ticket #' . $ticketId],
['automation' => 1234, 'context' => ['ticketId' => $ticketId]]
);
// Automation 1234 runs on arrival; inside it, trigger('parameters') holds
// { result, error, context, asyncRunId, request } — e.g. trigger('parameters').result.text
See The Answer Live In The Cloud Console
// No continuation target needed — the result is pushed back to this console tab:
$api->ai->promptAsync(
['model' => 'gpt-5', 'prompt' => 'Explain async wait-points in one paragraph'],
['then' => 'return $result["text"];'] // runs here on arrival, with $result in scope
);
Pause An Automation Until A Long Prompt Finishes
When an automation step genuinely needs the answer before continuing, wrap the dispatch in a wait-point and suspend — the model posts back to the wait-point's callback URL, and the flow resumes at On async completed:
$api->vars->setVariable('docId', $docId);
$api->flow->asyncWaitpoint(
fn(array $cb) => $api->ai->promptAsync(
['model' => 'gpt-5', 'prompt' => $api->vars->getVariable('longPrompt'), 'response_format' => 'json'],
['callbackUrl' => $cb['callbackUrl']] // dispatch to THIS wait-point, not a new run
),
['timeout' => '+5 minutes', 'continueMainFlow' => false]
);
// resumes at "On async completed"; endpoint('result') holds { text, json, usage, finish_reason }
This is the AI counterpart of the webhook example: the wait-point owns the callback and the
snapshot, and promptAsync(..., ['callbackUrl' => …]) simply dispatches the model call to it.
When the suspended prompt is an agentic run, also forward the wait-point's turnCallbackUrl so
each turn surfaces at the PHP action's On async turn endpoint while the flow stays suspended —
observe it, or steer it with a Send JSON response action (see Steering a run per turn below):
$api->flow->asyncWaitpoint(
fn(array $cb) => $api->ai->promptAsync(
['agent' => 'ops_executor', 'prompt' => $api->vars->getVariable('task')],
['callbackUrl' => $cb['callbackUrl'], 'turnCallbackUrl' => $cb['turnCallbackUrl']]
),
['continueMainFlow' => false, 'timeout' => '+10 minutes']
);
// On async turn fires per turn (turnIndex/text/tool_calls/usage/finish_reason/state);
// On async completed fires once at the end with the final result.
Agentic Runs (Multi-Turn, With Tools)
Instead of a single completion, promptAsync can run an agentic loop — the model calls
MCP tools (the tenant's data model + actions) across multiple turns before producing a final
answer. Enter agentic mode by naming an agent or setting agentic: true:
agent— the id of a named agent (configured in Customization → AI agents). It supplies the provider, model, system prompt, API key, and the user whose roles scope the MCP tools.agentic— settrueto run the inlinemodel/system/prompt/max_iterationsconfig as an agent under the current user (no named agent). When both are given, the named agent wins.max_iterations— hard cap on the model↔tool loop (default 15, max 50).
Agentic runs are always asynchronous (loops routinely take minutes) — there is no synchronous
prompt() form.
onTurn
In a Cloud Console context, the continuation may carry an onTurn snippet alongside then. It
runs once per model turn while the agent loops, with the turn record injected as $turn (plus
$context and $asyncRunId):
// In the Cloud Console — watch the agent work, turn by turn:
$api->ai->promptAsync(
['agent' => 'support_triage', 'prompt' => 'Investigate ticket #' . $ticketId],
[
'onTurn' => 'return "turn " . $turn["index"] . ": " . json_encode($turn["tool_calls"]);',
'then' => 'return $result["text"];',
]
);
$turn is compact: index, state, text (the assistant text for that turn), tool_calls
(the tools the model requested this turn — name + args), usage (cumulative token usage),
and finish_reason. Each turn arrives over the same status-only SSE ping (status: 'turn')
followed by an authenticated pull, then renders as a new console entry. onTurn is
observational — its output is shown live but its variable writes do not persist between turns
(the final then owns the session state).
The automation Run an agent action exposes the same per-turn stream as its On turn endpoint; there, variables you set in On turn do persist into later turns and into On success.
Steering a run per turn (onTurnHandler / onTurnAutomation)
Beyond observing, you can alter the run on each turn — useful for deterministic flow control
(e.g. look up a database based on the turn so far and feed guidance back to the model). The
sidecar awaits each turn callback, so whatever your turn target returns is applied before the
next turn. Give promptAsync a per-turn target:
onTurnHandler— a custom-app BFF route (['onTurnHandler' => 'agent/turn', 'onTurnApp' => slug?, 'onTurnMethod' => 'POST'?]), run on every turn; defaults to the current app.onTurnAutomation— a (different) automation id, run on every turn.
The target receives the turn as $turn (plus $context and $asyncRunId) and returns a
control object via $api->sendJson([...]) (BFF) or a Send JSON response action
(automation):
// dispatch from a custom-app BFF handler or a Custom PHP automation action:
$api->ai->promptAsync(
['agent' => 'ops_executor', 'prompt' => 'Reconcile invoice ' . $invoiceId],
[
'handler' => 'invoices/agent-done', // final result (fire-and-continue)
'onTurnHandler'=> 'invoices/agent-turn', // runs every turn, can steer
'context' => ['invoiceId' => $invoiceId],
]
);
// handler invoices/agent-turn — $turn (the compact turn record), $context, $asyncRunId injected:
$onHold = $api->resources->count('invoice_hold', ['invoice' => $context['invoiceId']]) > 0;
if ($onHold) {
$api->sendJson(['stop' => true]); // end the run early
} else {
$api->sendJson(['inject' => 'Account is in good standing — proceed.']);
}
The control object the target returns (and the sidecar applies before the next turn):
inject— a string (a user message) or a list of{role, text}(role∈user/system/assistant); appended to the conversation before the next turn.stop—trueto end the loop early and finalize with the current answer.
Returning nothing (no sendJson, or a 204) leaves the run unaltered.
The $turn object you inspect: index, state, text (the assistant text this turn),
tool_calls (tools the model requested this turn — name + args), usage (cumulative token
usage), finish_reason.
The same contract works from the automation Run an agent action's On turn endpoint — run a Send JSON response action there returning the control object:
Run an agent
└─ On turn
├─ Run a query / Custom PHP → decide based on $turn + your data
└─ Send JSON response → { "inject": "Focus on overdue lines first." }
(or { "stop": true } to finish now)
Keep turn logic fast: the turn callback blocks that turn until your target returns, so a slow target stalls the agent loop. Turn delivery is best-effort — a failed turn callback just means no steering for that turn.
Best Practices
- start the background work inside
$task— never in code after the call when suspending - carry state across the wait with
$api->vars->setVariable(...)before the call; locals are not preserved - always set a
timeoutso a wait-point that never gets a callback cleans itself up - keep suspension opt-in and deliberate (
continueMainFlow: false) — nothing should pause by surprise - treat the callback URL as a capability: it is unguessable and single-purpose; the SSE ping carries status only
- use the synchronous
$api->ai->prompt()for short prompts; reach forpromptAsynconly when the answer may outlast the request budget