ctx.callFunctionAsync(path, input?) queues a call to another route in your project and returns as soon as the call is accepted, without waiting for the target to run. The target executes as a brand-new, independent execution. Reach for it when a handler needs to kick off work it doesn’t want to wait on, or when a job is better split across a fresh execution than run to completion inside one long-lived handler.
Calling another function asynchronously
// functions/leads/webhook.post.ts, POST /leads/webhook
import { defineFunction } from '@trayai/helix-sdk';
export default defineFunction(async (ctx) => {
await ctx.callFunctionAsync('leads/enrich', { leadId: ctx.input.leadId });
return { received: true }; // returns immediately; enrichment runs separately
});
The target is an ordinary route: no special export, file location, or registration is required. It can read ctx.parentExecutionId to see which execution started it, null when it wasn’t started this way:
// functions/leads/enrich.post.ts, POST /leads/enrich
import { defineFunction } from '@trayai/helix-sdk';
export default defineFunction(async (ctx) => {
ctx.log.info('Enriching lead', { leadId: ctx.input.leadId, calledBy: ctx.parentExecutionId });
});
Fire-and-forget, not request-response
callFunctionAsync resolves once the call is accepted, not once the target finishes. There is no return value, status, or execution ID to get back. If a caller needs the result of the work, it has to fetch it some other way, for example by having the target write to the key-value store and polling for it.
Chaining is capped at two hops
A chain is at most two hops deep. The execution that starts a chain, HTTP-triggered or scheduled, may call callFunctionAsync; the execution that call starts may call it again; the execution after that may not. This limits how deep a chain can go, not how many independent calls one execution can make, so design a chain as at most three executions total: the original, one async hop, and one more.
The call always dispatches as POST
The target route must accept POST, regardless of which method the caller itself handled. A target that only exports .get.ts or .put.ts won’t receive the call.
Delivery and idempotency
Delivery is at-least-once, with no idempotency key. A transient failure can cause the target to run again for the same call, so write target handlers to be safe to run more than once, the same idempotency concern as scheduled functions.
Local development
helix dev doesn’t create a separate execution for an async call. The call fires directly at the target route on the same dev server and returns immediately, so you can exercise the whole chain locally. ctx.parentExecutionId is always null in dev, since there’s no real calling execution to report, and that’s a permanent property of local dev rather than a gap that’s expected to close. Don’t gate local testing of a target on that field being set.
Limits
See the limits reference for the chaining cap and delivery guarantee. Full signature and the ctx.parentExecutionId field are covered in the context reference.