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):

  1. 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.
  2. 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).
  3. Returns immediately (fire-and-continue) — unless you explicitly ask it to suspend the current automation.
  4. 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 the asyncWaitpoint(...) call does not run when you suspend.

Choosing A Continuation

ContinuationWhat runs on callbackSuspends?
(none, inside an automation PHP action)THIS automation, at the action's On async completed endpointonly with continueMainFlow: false
continuation: ['automation' => id]that automation, fresh, with the result as trigger parametersno
continuation: ['handler' => 'route/key']a custom-app BFF routeno
continuation: ['notify' => channel] (auto-stamped in the Cloud Console)nothing — pushes a {asyncRunId, status} ping to a live UIno

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:

  1. setVariable records contractId (captured in the snapshot).
  2. asyncWaitpoint mints the callback URL, snapshots the automation, and records a pending run with a 2-day timeout.
  3. $task fires the HTTP POST, handing the provider callbackUrl as their webhook.
  4. With continueMainFlow: false, the automation suspends — the request ends; nothing after the call runs.
  5. 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, where endpoint('requestContent') holds the provider's JSON. The run is marked completed.
  6. 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:

  1. The open console is already subscribed to its live channel.
  2. The call records a notify run tied to that channel and this tab, fires $task, and returns the asyncRunId immediately (the console never suspends — it is not an automation).
  3. 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.
  4. The console receives the ping and pulls the result over an authenticated, owner-scoped path, then runs your then snippet 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 $asyncRunId in scope.
  5. 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 — set true to run the inline model/system/prompt/max_iterations config 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} (roleuser/system/assistant); appended to the conversation before the next turn.
  • stoptrue to 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 timeout so 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 for promptAsync only when the answer may outlast the request budget