On this page
Calls, results and events
Use @cumulus/sdk/runtime when your application starts a phone call, waits for an outcome or follows run events. The helpers work with the root Cumulus client; keep that client's API key on your server.
A call has several milestones: admission, conversation end, recording and analysis. Read each one independently. A completed conversation does not tell you whether a booking succeeded or whether its recording is ready.
Prepare and send one call
prepareCall validates a phone request and gives it a stable idempotency key and body digest. It makes no API request. Save that command before sending it so a timeout can be retried as the same intended call.
This local example uses a fictional recipient, +12025550123. Replace it with an authorized test destination before sending. test: true still dials a real phone and can invoke real tools.
Save as prepare-call.mjs. Set AGENT_ID and AGENT_VERSION to an existing publication you intend to test:
import { mkdir, open } from 'node:fs/promises';
import { prepareCall } from '@cumulus/sdk/runtime';
const agentId = process.env.AGENT_ID;
const version = Number(process.env.AGENT_VERSION);
if (!agentId || !process.env.AGENT_VERSION || !Number.isInteger(version) || version < 0) {
throw new Error('Set AGENT_ID and AGENT_VERSION');
}
const command = prepareCall({
channel: 'phone', agent_id: agentId, version,
to: '+12025550123', // Fictional: replace with your authorized test recipient.
test: true,
dynamic_variables: { request_topic: 'repair' },
});
await mkdir('.cumulus', { recursive: true, mode: 0o700 });
const file = await open('.cumulus/call-command.json', 'wx', 0o600);
try {
await file.writeFile(JSON.stringify(command));
await file.sync();
} finally {
await file.close();
}
console.log('Saved call command; no call has been placed.');
The exclusive file create refuses to overwrite an earlier command. Keep this file private: it contains the recipient and call context. In a production service, store the command in your durable database or outbox before dispatch; the file is a small local example, not a job queue.
Save this separate sender as send-call.mjs. It reads the saved command rather than preparing another one:
import { readFile, writeFile } from 'node:fs/promises';
import { Cumulus } from '@cumulus/sdk';
import { placeCall } from '@cumulus/sdk/runtime';
const baseUrl = process.env.CUMULUS_API_URL;
const apiKey = process.env.CUMULUS_API_KEY;
if (!baseUrl || !apiKey || process.env.CONFIRM_REAL_CALL !== 'yes') {
throw new Error('Set API origin/key and CONFIRM_REAL_CALL=yes after reviewing the command');
}
const client = new Cumulus({ baseUrl, apiKey });
const command = JSON.parse(await readFile('.cumulus/call-command.json', 'utf8'));
const run = await placeCall(client, command);
await writeFile('.cumulus/call-result.json', JSON.stringify(run), { mode: 0o600 });
console.log({ run_id: run.run_id, state: run.state });
placeCall verifies the digest, sends the saved body and reuses its idempotency key. The response identifies an admitted run; it is not the final conversation result. If the request times out, preserve the command and retry that same intent. Do not prepare a fresh key just because the first response was lost. To start a different call, deliberately archive the earlier command and result, then prepare the new one. The CLI provides this saved-command workflow too.
prepareCall accepts an optional caller-supplied idempotencyKey of 8–255 visible ASCII characters, without spaces. Optional request fields include from, string-valued dynamic_variables and string-valued metadata. Keep sensitive verification answers out of ordinary context.
Retrieve the conversation and its artifacts
Save this read-only script as results.mjs. Set CUMULUS_API_URL, CUMULUS_API_KEY and RUN_ID for the run you want to inspect:
import { Cumulus } from '@cumulus/sdk';
import { waitUntilEnded, waitForRecording, waitForAnalysis } from '@cumulus/sdk/runtime';
const baseUrl = process.env.CUMULUS_API_URL;
const apiKey = process.env.CUMULUS_API_KEY;
const runId = process.env.RUN_ID;
if (!baseUrl || !apiKey || !runId) throw new Error('Set API origin/key and RUN_ID');
const client = new Cumulus({ baseUrl, apiKey });
const ended = await waitUntilEnded(client, runId, { timeoutMs: 120000 });
const path = { run_id: runId };
const [transcript, effects, usage, result] = await Promise.all([
client.runs.transcript({ path }), client.runs.effects({ path }),
client.runs.usage({ path }), client.runs.result({ path }),
]);
const artifacts = await Promise.allSettled([
waitForRecording(client, runId, { timeoutMs: 120000 }),
waitForAnalysis(client, runId, { timeoutMs: 120000 }),
]);
console.log({
run: ended.state,
retrieved: ['transcript', 'effects', 'usage', 'result'],
artifacts: artifacts.map(item => item.status === 'fulfilled'
? { state: item.value.state }
: { wait: 'stopped; inspect this resource separately' }),
});
The script fetches the resources without printing transcript text, phone numbers or signed recording URLs. Inspect effects for action outcomes and result for your workflow's output. Recording and analysis waits are independent: one may succeed while the other fails or times out.
| Helper | Stops when |
|---|---|
waitUntilEnded | Run state is completed, failed, cancelled, rejected or expired. |
waitForRecording | Recording state is ready, partial, failed, not_required or expired. |
waitForAnalysis | Analysis is no longer pending, running or processing; inspect the returned state. |
Waits default to two minutes with one-second polling. Set timeoutMs, pollMs or an abort signal as needed. timeoutMs must be greater than zero and pollMs at least 10 milliseconds. WaitTimeout ends your wait; it does not cancel the call. Always inspect the returned state, including unsuccessful terminal states.
Use analysis values with their evidence
Cumulus applies the publication's analysis definitions to the final transcript. A supported extracted value has { status: 'known', value, evidence }. If the transcript does not support an answer, the field is { status: 'unknown', reason: 'insufficient_evidence' }. Check status before using value in a business decision.
Analysis does not automatically join previous calls or your customer database. Supply that authorized context explicitly. Later retrieval also depends on retention, access permissions and recording URL expiry.
Follow events and larger result sets
client.events and client.stream consume server-sent events. watchRunEvents(client, runId, options) adds bounded reconnects and supports lastEventId, maxReconnects (default five), reconnectMs (default 500), signal and onCursor.
Process and durably save a yielded event before advancing consumption; deduplicate event IDs after reconnects. The onCursor callback is synchronous. If saving your cursor is asynchronous, await that save in your consumption loop rather than launching an unawaited callback. Live progress hints are not replayable journal events.
paginate(pageFunction) follows next_cursor and rejects repeated cursors. Adapt the endpoint's response to { items, next_cursor } when it uses a named collection. Use the API reference for each operation's response shape.
Number routing uses assignIntegration; it does not place calls. See bindings and integrations for its head check and assignment behavior.