Source: https://docs.cumuluslabs.io/sdk-runtime
Content version: cab523a7

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

```javascript
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:

```javascript
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](/docs/sdk-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:

```javascript
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](/docs/api-reference) for each operation's response shape.

Number routing uses `assignIntegration`; it does not place calls. See [bindings and integrations](/docs/sdk-bindings#compile-publish-and-assign-routes) for its head check and assignment behavior.
