Source: https://docs.cumuluslabs.io/sdk-tutorial
Content version: c0a6becb

# Build a voice agent in your application

Build a service desk that answers delivery or repair questions using information from your backend. You will define the conversation in TypeScript, implement its lookup tool, test it locally and prepare it for a dedicated phone number.

The example uses fictional guidance and makes no bookings or account changes. It has the same boundaries you would use for a business integration: the agent decides when to request a tool, your backend handles the request, and the agent explains the returned result. When the lookup fails, it says so.

Cumulus runs the conversation. Your application runs the tool and owns its database. Keep both definitions and backend code in your existing repository.

[Watch Build a voice agent with TypeScript](https://docs.cumuluslabs.io/docs-assets/demos/sdk-workflow.mp4)

[Transcript and chapters](/docs-assets/demos/sdk-workflow-transcript.md)

## 1. Install the example

Download the [complete service-desk example](/docs-assets/examples/service-guide.zip) and obtain the SDK 0.3.4 package archive from your Cumulus contact. Use Node 22.13 or newer for the example's built-in SQLite store. The SDK itself supports Node 22 or newer, ESM and CommonJS.

```sh
cd service-guide
mkdir -p vendor
cp /absolute/path/to/cumulus-sdk-0.3.4.tgz vendor/
npm ci --ignore-scripts
npm test
npm run validate
```

The final command builds TypeScript and checks the agent definition with the installed `cumulus` CLI. It prints `Local definition valid; server publication validation remains authoritative`. You do not need an API key for these local steps, and they place no calls.

The project contains three pieces you will customize: `src/project.ts` defines the agent and its environment, `src/tools.ts` declares the tools, and `src/application.ts` implements them. The excerpts below come from those complete files.

## 2. Define the conversation

The agent begins by asking whether the caller wants delivery or repair information. It then calls your lookup tool. A successful result leads to an explanation; a failure leads to an apology and a way to finish.


A node is one step. A conversation node contains instructions; a function node calls a tool. In `src/project.ts`, the lookup node points to the tool defined in the next section:

```typescript
const lookup = defineNode({
  key: 'lookup',
  kind: 'function',
  tool: guide,
  waitForResult: true,
});
```

The transitions determine what happens after the request. Use the function node's `always_edge` for its normal result and `else_edge` for failure:

```typescript
defineEdge({
  from: lookup, to: explain, slot: 'always_edge',
  condition: { type: 'always' },
});
defineEdge({
  from: lookup, to: failed, slot: 'else_edge',
  condition: { type: 'always' },
});
```

`defineAgent` combines the nodes, transitions and tools. These functions build data; they do not start a local voice process or upload handler functions. See [graph definitions](/docs/sdk-definitions) for a complete minimal definition and other node types.

## 3. Describe the tool

The tool contract tells Cumulus what arguments to send and what response to expect. This lookup accepts one topic and returns guidance:

```typescript
// src/tools.ts
import { defineTool } from '@cumulus/sdk/agent';
export const guide = defineTool({
  key: 'service_guide',
  description: 'Read delivery or repair guidance from the service desk.',
  input: {
    type: 'object',
    properties: { topic: { type: 'string', enum: ['delivery', 'repair'] } },
    required: ['topic'], additionalProperties: false,
  },
  output: {
    type: 'object', properties: { guidance: { type: 'string' } },
    required: ['guidance'], additionalProperties: false,
  },
  execution: {
    type: 'http', binding: 'customer_api', path: '/cumulus/tools/guide',
    method: 'POST', timeoutMs: 3000,
    idempotency: { mode: 'effect_key' },
  },
  responseVariables: { service_guidance: '$.guidance' },
} as const);
```

The input schema constrains the tool's `topic` argument. `responseVariables` makes the returned `guidance` available to the graph as `service_guidance`. The explanation node instructs the agent to use that result. Your backend—not the model—supplies the actual service information.

## 4. Implement it in your backend

`implementTool` connects the contract to your code. The example route delegates to `GuideWorkflow`, which uses `GuideRepository` for persistence:

```typescript
const implementation = implementTool(guide, (input, context) =>
  workflow.execute(input, context)
);
const handler = createToolHandler(implementation, {
  auth: {
    scheme: 'bearer',
    integration: 'service_guide',
    secret: customerToolSecret,
  },
});
```

`createToolHandler` authenticates the request, validates its arguments, enforces the deadline and checks the response. Mount `nodeHandler(handler)` before a JSON body parser so it receives the original bytes. The complete example includes the Node server.

A local delivery lookup returns:

```json
{
  "guidance": "Fictional delivery desk: keep your reference ready and contact dispatch. This demo makes no booking."
}
```

The request also identifies its conversation (`call.call_id`), platform run (`call.run_id`) and tool effect (`Idempotency-Key`). Use the run ID with the runs API. The effect ID lets your application recognize a repeated request.

The example saves the lookup result and effect receipt together in SQLite. The same effect with the same input returns its saved result; changed input is rejected. Tests reopen the database to check this behavior. If you replace the lookup with a booking or another external operation, persist its intent and reconcile an uncertain response before retrying. A timeout does not undo an operation already sent.

Inject `CUSTOMER_TOOL_SECRET` and `CUSTOMER_EVENT_SECRET` into your development environment, then run `npm start`. Use at least 16 characters for the tool secret and 32 for event signing. The server listens locally on port 8787. To connect it to Cumulus, deploy it behind your own HTTPS URL on port 443. Requests stay authenticated; do not embed credentials in the URL.

## 5. Connect the environment

The graph uses logical names such as `customer_api`. Environment bindings supply the endpoint and secret reference for the workspace where you will publish it. Your backend receives the matching secret bytes separately.

Set `SERVICE_GUIDE_REMOTE=yes` and the variables listed in the example README: customer tool/event URLs, secret IDs and versions, and `TEST_NUMBER_ID`. Use the opaque secret IDs and versions returned by Cumulus. `TEST_NUMBER_ID` is the platform resource ID of an imported dedicated number; the recipient of an outbound call is a separate phone number.

The example integration also connects an inbound context resolver and signed event receiver. The resolver returns a declared `demo_channel` string. The receiver saves event IDs before acknowledging them so repeated notifications are safe. [Bindings and integrations](/docs/sdk-bindings) explains these options.

Before publishing, your workspace needs a configured conversation model, speech providers and voice, permission to make calls, and a carrier trunk with the imported test number. Your Cumulus administrator can help with [phone setup](/docs/phone).

## 6. Review and publish

Set `CUMULUS_API_URL` and `CUMULUS_API_KEY` for your development workspace. Run a preview:

```sh
./node_modules/.bin/cumulus preview --project cumulus/project.mjs --agent service_guide
```

Review the printed tenant and changes. Preview reads the server and saves a plan locally. Apply that plan and publish an ordinary callable version:

```sh
./node_modules/.bin/cumulus apply --project cumulus/project.mjs --agent service_guide
./node_modules/.bin/cumulus publish --project cumulus/project.mjs --agent service_guide --live
```

Apply checks the draft revision so it cannot silently overwrite another author's Studio edits. Publish validates the draft and creates an immutable version with a digest. `--live` also selects that version as the agent's head. Publishing without it creates a test-only version.

The CLI keeps receipts next to `cumulus/project.mjs` in an ignored `.cumulus` directory. Keep these files across builds: they record which draft and version your source manages.

## 7. Assign the number and make a call

First verify the hosted tool with a synthetic authenticated request. Then assign the dedicated number to the published agent:

```sh
./node_modules/.bin/cumulus route --project cumulus/project.mjs --agent service_guide
```

Routing changes the imported number's assignments. Publishing alone does not change those assignments or place a call.

For inbound testing, call the dedicated number and ask for delivery or repair information. For outbound testing, replace the fictional number below with an owned or consenting test recipient:

```sh
./node_modules/.bin/cumulus call --project cumulus/project.mjs --agent service_guide   --to +12025550123 --confirm-real-call
```

This places a real call. `test: true` also dials and can invoke tools. The CLI saves `call-command.json` before sending. Repeating the command retries the same call admission; it does not request a new call. The file is at:

```text
cumulus/.cumulus/<tenant-id>/service_guide_project/service_guide/call-command.json
```

Use the `tenantId` printed by `preview` for `<tenant-id>`. After confirming the previous call's result, move that file to an archive before requesting a new call:

```sh
export CUMULUS_TENANT_ID='your-tenant-id'
call_dir="cumulus/.cumulus/$CUMULUS_TENANT_ID/service_guide_project/service_guide"
mv "$call_dir/call-command.json" "$call_dir/call-command.$(date -u +%Y%m%dT%H%M%SZ).json"
```

Keep the receipt and archived commands for recovery. Keep personal numbers and credentials out of shared recordings.

## 8. Read the result and make the next change

The call admission returns a `run_id`. Set it as `RUN_ID` alongside your API URL and key, then save and run this as `read-run.mjs`:

```javascript
import { Cumulus } from '@cumulus/sdk';
import { waitUntilEnded } 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 the API URL, key and RUN_ID');
const client = new Cumulus({ baseUrl, apiKey });
const run = await waitUntilEnded(client, runId, { timeoutMs: 120000 });
const path = { run_id: runId };
const transcript = await client.runs.transcript({ path });
const effects = await client.runs.effects({ path });
console.log(JSON.stringify({
  run: run.state,
  transcript: transcript.state,
  utterances: transcript.utterances,
  effects,
}, null, 2));
```

For a normally ended call with a complete transcript, the states are `completed` and `complete`. Read the printed utterances to check what the caller asked and what the agent said. Inspect the effects for the lookup attempt and its result; a completed call alone does not prove the lookup succeeded. Wait for recording and configured analysis
separately; they can finish later. [Calls and results](/docs/sdk-runtime) includes the
artifact waits and explains each possible state.

Check what the caller heard, what the tool returned and how the call ended. A call ending successfully does not by itself tell you whether a business operation succeeded.

To change the agent, edit the source, validate locally and preview again. If someone edited the draft in Studio, export the public draft, review and backport the intended change, then adopt that reviewed revision. Follow [Studio collaboration and recovery](/docs/sdk-lifecycle) for the commands and interruption handling.
