Thus far you've seen the ability to talk to other people using Relay's push-to-talk capabilities, like a walkie-talkie. This is half-duplex, only one person can talk at a time, and you have to hold the Talk button to transmit audio.

Since an Enterprise plan for Relay is required for doing workflows, then you also have the ability to use the additional paid feature of full-duplex calling in Relay. It's like a speakerphone, you don't need to hold the Talk button during the call. Give it a quick try, as described below, no setup needed.

Try Built-In Calling

To try out the built-in calling capabilities without a workflow, you'll first need to know the name of the device that you want to call. (If you tap the Assistant button once, the device will announce its name and the current channel.) In this example, my device name is Alice and I want to call the device named Bob. So on my device (Alice) I hold down the Assistant button on the side, and say the built-in command "call Bob" and let go of the Assistant button. Assuming the assistant recognized my voice command, I'll get a confirmation beep, the LED ring on my device should turn green, and it should announce "Calling Bob". You'll hear a telephone-like ringing until Bob answers or it times out.

Meanwhile, on Bob's device, its LED ring will turn green and it will sound out a ringing tone like a telephone ringer, and vibrate. It will also announce "Call from Alice." Assuming Bob wants to take the call, he would tap the Talk button once. After a moment, the call should be set up, and live audio should be streaming in both directions and the LED rings will stay green. Use the Relay device as if it was a speakerphone, no need to hold down the Talk button. Just start talking. When either Alice or Bob is ready to end the call, one of them can tap the Assistant button twice to hang up. The other party will also hang up automatically, only one person needs to do it. On both devices it will announce "Call ended", and the LED ring is no longer green.

If Bob doesn't want to take the call, he can tap the Assistant button twice or the Talk button twice, while it is ringing to reject the call. On Bob's device it will announce "Call from Alice ignored", and on Alice's device it will announce "Bob is not available."

If Bob doesn't answer and doesn't reject the incoming call before Alice gets tired of waiting and hangs up, then there will be an alert that will play out on Bob's device that says "You missed a call from Alice x minutes ago. Tap the Talk button to clear." This message will repeat every 30 seconds until Bob acknowledges this missed call by tapping the Talk button.

There is no time limit to a call duration.

Now that you've seen what Relay calling looks like, you'll have a better idea of what is possible. Below we'll talk about the APIs for calling, which can be used to programmatically initiate a call without saying "call Bob", or which can be used to control the user experience of the call.

Methods and Events

Here is a list of the call-specific methods that can be invoked:

  • placeCall: invoked by the sending device to make an outgoing call.
  • answerCall: invoked by the device that is receiving an inbound call.
  • hangupCall: invoked by either the the receiver or the sender, as long as you have a workflow with the call ID.

And here is a list of the call-specific events that can be received that you can write handlers for:

  • CALL_RECEIVED: (on receiver) there is an incoming call that typically should be answered.
  • CALL_CONNECTED: (on receiver and sender) the incoming call has been answered and it connected and live.
  • CALL_DISCONNECTED: (on receiver and sender) a previously-connected call is now disconnected.
  • CALL_FAILED: (on receiver and sender) the call encountered a problem before it could get connected and live.
  • CALL_START_REQUEST: (on sender) there is a request for this device to start an outbound call.
  • CALL_RINGING: (on sender) the call is ringing on the receiver
  • CALL_PROGRESSING: (on sender) the call is working through the connecting process. You may also see CALL_RINGING.

Controlling the Inbound Call User Experience

Perhaps you want to customize the user experience, such as automatically answering an incoming call. One potential reason for this might be that the receiver's work environment has their hands busy, such as in a hospital avoiding hand contamination, or food preparation in a kitchen.

To do that, first we define a trigger to start our workflow, based on receiving an inbound call. For our example use case here, we'll use an incoming call. This is what the CLI command would look like for that, notice that the trigger is not a spoken phrase or button press like we've used previously:

relay workflow create call --trigger inbound --name my_auto_answer --uri wss://myhost.mydomain.com/autoanswerpath --install 990007560045678

The user experience of the ringing, announcement, vibration, etc, and even answering, is defined in a default workflow inside of the Relay server. Once we register our own workflow for an inbound or outbound call, then that default workflow no longer runs because our workflow gets called instead. So our workflow is now responsible for all the user experience elements for the call, including the answering and creating an interaction. The one exception is that the double-tap of the Assistant button to hang up is still provided automatically. If desired, you could provide an alternate way for the user to hang up.

Note that instead of using --install-all in the workflow registration, we registered this workflow against a single device. Because this registration will affect all incoming calls on the installed devices, if we had used --install-all, that would means that all incoming calls across all devices would get auto-answered, which likely isn't what we want. We want just the device in the kitchen to have this behavior.

Now we write the code for our calling workflow. Per normal, when the trigger occurs, we should expect a START event to fire first. In our START handler, we should start an interaction. This interaction will help us with any actions we want to send to the device, and help us receive events from the device like button presses. Because we start an interaction, we should expect to also receive an INTERACTION_LIFECYCLE event. But we don't need to do anything there yet, because there will be another event received later:

We should expect to see a CALL_RECEIVED event. Therefore we should write a handler method for that CALL_RECEIVED event. And since we want an inbound call to be automatically answered, in that CALL_RECEIVED handler we should invoke the API to answer the incoming call. The answerCall method needs the call ID which is in the CALL_RECEIVED event, and the id of the receiving device we want to answer on which was in the START event.

The CALL_RECEIVED event does not capture the device id/name of the receiving device. The device_id and device_name in the CALL_RECEIVED event is the calling device, not the receiving device. However, the START event does have the receiving device, so we'll capture it there via a global variable so we can use it later.

...
interaction_name = "answering_interaction"
receiving_device = None
my_interaction_uri = None


@mywf.on_start
async def start_handler(workflow, trigger):
    global receiving_device
    receiving_device = workflow.get_source_uri_from_trigger(trigger)
    target = workflow.make_target_uris(trigger)
    await workflow.start_interaction(target, interaction_name)


@mywf.on_call_received
async def received_handler(workflow, call_id, direction, device_id, device_name, uri, on_net, start_time_epoch):
    await workflow.answer_call(receiving_device, call_id)


@mywf.on_interaction_lifecycle
async def interaction_handler(workflow, itype, interaction_uri: str, reason):
    if itype == relay.workflow.TYPE_STARTED:
        # capture the interaction uri in a global var for use in other event handlers
        global my_interaction_uri
        my_interaction_uri = interaction_uri
    elif itype == relay.workflow.TYPE_ENDED:
        await workflow.terminate()
...
...
  const interactionName = `answering_interaction`
  var receivingDevice = null
  var myInteractionUri  = null

  workflow.on(Event.START, async (event) => {
    const { trigger: { args: { source_uri } } } = event
    receivingDevice = source_uri
    await workflow.startInteraction(receivingDevice, interactionName)
  })

  workflow.on(Event.CALL_RECEIVED, async({workflow, call_id, device_id, device_name, direction, onnet, start_time_epoch, uri}) => {
    console.log(receivingDevice)
    console.log(call_id)
    await workflow.answerCall(receivingDevice, call_id);
  })

  workflow.on(Event.INTERACTION_STARTED, async ({ source_uri }) => {
    myInteractionUri = source_uri
  })

  workflow.on(Event.INTERACTION_ENDED, async() => {
    await workflow.terminate()
  })
...
using System.Collections.Generic;
using RelayDotNet;
using Serilog;
using Serilog.Events;

namespace SamplesLibrary
{
    public class CallingOutboundWf : AbstractRelayWorkflow
    {

        private string interactionName = "answering_interaction";
        private string receivingDevice = null;
        private string myInteractionUri = null;


        public CallingOutboundWf(Relay relay) : base(relay)
        {
        }

        public override void OnStart(IDictionary<string, object> startEvent)
        {
            receivingDevice = Relay.GetSourceUriFromStartEvent(startEvent);
            Relay.StartInteraction(this, receivingDevice, interactionName);
        }

        public override async void OnCallReceived(IDictionary<string, object> dictionary)
        {
            Log.Logger.Information("OnCallReceived: {0}", dictionary.ToString());
            await Relay.AnswerCall(this, receivingDevice, (string) dictionary["call_id"]);
        }

        public override void OnInteractionLifecycle(IDictionary<string, object> lifecycleEvent)
        {
            var type = (string) lifecycleEvent["type"];
            
            if (type == InteractionLifecycleType.Started)
            {
                myInteractionUri = (string) lifecycleEvent["source_uri"];
            }
            else if (type == InteractionLifecycleType.Ended)
            {
                Relay.Terminate(this);
            }
        }

        private static void InitLogging()
        {
            Log.Logger = new LoggerConfiguration()
                .MinimumLevel.Debug()
                .WriteTo.Console(LogEventLevel.Information,
                    outputTemplate: "{Timestamp:HH:mm:ss.fff} [{ThreadId}] [{ThreadName}] [{Level:u3}] {Message} {NewLine}{Exception}")
                .Enrich.WithThreadId()
                .CreateLogger();
        }
    }
}
...
public class CallingOutboundWf {

    public static String interactionName = "answering_interaction";
    public static String receivingDevice;
    public static String myInteractionUri;
    public static void main(String... args) {
        int port = 8080;
        Map<String, String> env = System.getenv();
        try {
            if (env.containsKey("PORT") && (Integer.parseInt(env.get("PORT")) > 0)) {
                port = Integer.parseInt(env.get("PORT"));
            }
        } catch (NumberFormatException e) {
            System.err.println("Unable to parse PORT env value as an integer, ignoring: " + env.get("PORT"));
        }
        Relay.addWorkflow("callpath", new MyWorkflow());

        JettyWebsocketServer.startServer(port);
    }

    private static class MyWorkflow extends Workflow {

        @Override
        public void onStart(Relay relay, StartEvent startEvent) {
            super.onStart(relay, startEvent);

            receivingDevice = Relay.getSourceUriFromStartEvent(startEvent);
            relay.startInteraction( receivingDevice, interactionName, null);
        }

        @Override
        public void onCallReceived(Relay relay, CallReceivedEvent callReceivedEvent) {
            relay.answerCall(receivingDevice, callReceivedEvent.callId);
        }

        @Override
        public void onInteractionLifecycle(Relay relay, InteractionLifecycleEvent lifecycleEvent) {
            super.onInteractionLifecycle(relay, lifecycleEvent);

            String interactionUri = (String)lifecycleEvent.sourceUri;
            if (lifecycleEvent.isTypeStarted()) {
                myInteractionUri = interactionUri;
            }
            if (lifecycleEvent.isTypeEnded()) {
                relay.terminate();
            }
        }
    }
}
...
func main() {
  log.SetLevel(log.InfoLevel)

  sdk.AddWorkflow("hellopath", func(api sdk.RelayApi) {
    var interactionName string = "answering_interaction"
    var receivingDevice string
    var myInteractionUri string
    
    api.OnStart(func(startEvent sdk.StartEvent) {
      receivingDevice = api.GetSourceUri(startEvent)
      api.StartInteraction(receivingDevice, interactionName)
    })

    api.OnCallReceived(func(callReceivedEvent sdk.CallReceivedEvent) {
      api.AnswerCall(receivingDevice, callReceivedEvent.CallId)
    })

    api.OnInteractionLifecycle(func(interactionLifecycleEvent sdk.InteractionLifecycleEvent) {

      if interactionLifecycleEvent.LifecycleType == "started" {
        myInteractionUri = interactionLifecycleEvent.SourceUri
      }

      if interactionLifecycleEvent.LifecycleType == "ended" {
        api.Terminate()
      }
    })
  })

  sdk.InitializeRelaySdk(port)
}
...

In summary, as soon as the Relay server sees the incoming call, that is the trigger which starts your workflow. Next, your workflow receives a START event where you can do some pre-processing, like saving the id of the receiving device. Very shortly thereafter, your workflow should get a CALL_RECEIVED event about the time that the device would normally ring, but you can choose to immediately answer so that the call becomes fully live without requiring a button press.

Our workflow does not explicitly play any ring tones on the receiving device, so you won't hear any ring tones there.

Right now our workflow doesn't terminate. We need to take care of that. There are two events we should handle, CALL_DISCONNECTED which is the normal happy path to end, and CALL_FAILED which indicates a problem occurred. So let's add handlers for these two:

...
@mywf.on_call_failed
async def failed(workflow, call_id, direction, device_id, device_name,
                 uri, on_net, reason, start_time_epoch,
                 connect_time_epoch, end_time_epoch):
    await workflow.terminate()


@mywf.on_call_disconnected
async def disconnected_handler(workflow, call_id, direction, device_id, device_name,
                               uri, onnet, reason, start_time_epoch,
                               connect_time_epoch, end_time_epoch):
    print('seconds connected: ' + str(end_time_epoch - start_time_epoch))
    await workflow.terminate()
...
...
        public override void OnCallFailed(IDictionary<string, object> dictionary)
        {
            Relay.Terminate(this);
        }

        public override void OnCallDisconnected(IDictionary<string, object> dictionary)
        {
            Log.Information("seconds connected: {0}", (System.Int64) dictionary["end_time_epoch"] - (System.Int64) dictionary["start_time_epoch"]);
            Relay.Terminate(this);
        }
...
...
  @Override
  public void onCallFailed(Relay relay, CallFailedEvent callFailedEvent) {
    relay.terminate();
  }

  @Override
  public void onCallDisconnected(Relay relay, CallDisconnectedEvent callDisconnectedEvent) {
    int secondsConnected = Integer.parseInt(callDisconnectedEvent.endTimeEpoch) - Integer.parseInt(callDisconnectedEvent.startTimeEpoch);
    logger.info("seconds connected: "  + Integer.toString(secondsConnected));
    relay.terminate();
  }
...
...
  workflow.on(Event.CALL_FAILED, async({ call_id, direction, device_id, device_name,
                                        uri, on_net, reason, start_time_epoch, connect_time_epoch, end_time_epoch }) => {
  	workflow.terminate()
  })

  workflow.on(Event.CALL_DISCONNECTED, async({ call_id, direction, device_id, device_name,
                                              uri, onnet, reason, start_time_epoch, connect_time_epoch, end_time_epoch }) => {
    var secondsConnected = parseInt(end_time_epoch) - parseInt(start_time_epoch)
    debug(`seconds connected: ${secondsConnected}`)
    workflow.terminate()
  })
...
...
api.OnCallFailed(func(callFailedEvent sdk.CallFailedEvent) {
  api.Terminate()
})

api.OnCallDisconnected(func(callDisconnected sdk.CallDisconnectedEvent) {
  fmt.Println("Seconds connected: " + string(callDisconnected.EndTimeEpoch - callDisconnected.StartTimeEpoch)
  api.Terminate()
})
...

Perhaps we take our example further and wish for an announcement to be played on the receiving device upon the auto-answer, such as "Now in call." We can do that with a regular sayAndWait invocation. When the call is connected, a CALL_CONNECTED event that fires. So let's write a handler for that, where can invoke the sayAndWait. Add the following to our workflow:

...
@mywf.on_call_connected
async def connected_handler(workflow, call_id, direction, device_id, device_name, uri, onnet, start_time_epoch, connect_time_epoch):
    await workflow.say_and_wait(my_interaction_uri, "Now in call")
...
...
        public override async void OnCallConnected(IDictionary<string, object> dictionary)
        {
            await Relay.SayAndWait(this, myInteractionUri, "Now in call");
        }
...
...
  @Override
  public void onCallConnected(Relay relay, CallConnectedEvent callConnectedEvent) {
    relay.sayAndWait(myInteractionUri, "Now in call");
  }
...
...
  workflow.on(Event.CALL_CONNECTED, async({ call_id, direction, device_id, device_name, uri, onnet, start_time_epoch, connect_time_epoch }) => {
        await workflow.sayAndWait(myInteractionUri, "Now in call")
  })
...
...
api.OnCallConnected(func(callConnectedEvent sdk.CallConnectedEvent) {
  api.SayAndWait(my_interaction_uri, "Now in call", sdk.ENGLISH)
})
...

Any audio output that you create in your workflow, such as with sayAndWait will get mixed in to the speakerphone audio of the call, instead of displacing it. And the presence of the interaction created in our START handler will get device events to you, such as button presses.

And taking this example further, we can use a tap of the Talk button to hang up. A button tap will fire a BUTTON event, so let's add the following handler to our workflow:

...
@mywf.on_button
async def button_handler(workflow, button, taps, source_uri):
    await workflow.say_and_wait(my_interaction_uri, "hung up")
    await workflow.end_interaction(my_interaction_uri)
...
...
        public override async void OnButton(IDictionary<string, object> dictionary)
        {
            await Relay.SayAndWait(this, myInteractionUri, "hung up");
            Relay.EndInteraction(this, myInteractionUri);
        }
...
...
  @Override
  public void onButton(Relay relay, ButtonEvent buttonEvent) {
    relay.sayAndWait(myInteractionUri, "hung up");
    relay.endInteraction(myInteractionUri);
  }
...
...
	workflow.on(Event.BUTTON, async({ button, taps, source_uri }) => {
    await workflow.sayAndWait(myInteractionUri, "hung up")
    await workflow.endInteraction(myInteractionUri)
  })
...
...
api.OnButton(func(buttonEvent sdk.ButtonEvent) {
  api.SayAndWait(myInteractionUri, "hung up", sdk.ENGLISH)
  api.EndInteraction(myInteractionUri)
})
...

If you remember, we wrote interaction_handler such that an end of the interaction will cascade into a termination of the workflow.

📘

Workflow app must be available when inbound/outbound triggers are configured

For example, in the case above where we register a workflow with an inbound call trigger, this will affect all inbound calls to the devices that this workflow is registered with (installed). If our workflow server is not reachable or the workflow app there is not running, when we attempt to call that device, we will get the announcement "Bob is not available", even though the Bob device is online and connected to the Relay server and otherwise working.

So if you unexplicably are getting a "X is not available" during a call attempt when you expect it to work, check if you have a workflow registered for an inbound call but don't have the workflow app running.

Controlling the Outbound Call User Experience

You can similarly control the user experience for an outbound call. Just like the inbound call, there is a default workflow built in the Relay server that normally handles that. When you register your own workflow for the outbound call trigger, the server's default workflow does not run, and your workflow becomes responsible for all the user experience.

First, let's run the CLI command to register this new workflow to handle outbound calling. (If you are using the same devices from the inbound calling example above, you may want to delete the inbound calling registration so you can try just the outbound workflow for now.)

relay workflow create call --trigger outbound --uri wss://myhost.mydomain.com/outboundpath --name outbound --install 990007560023456

As you might expect, the workflow structure is similar to the inbound flow, but with different events, and the corresponding "outbound" actions.

On my device where this workflow is installed (Alice), I pick it up, and while holding the Assistant button I speak the command "call Bob". The Assistant recognizes my command, starts a call, and sees my workflow registration to handle outbound calling. It then starts my workflow and sends it a START event. Immediately thereafter, the workflow will receive a CALL_START_REQUEST event. This signifies that there is a request to start an outbound call. In that event will be the uri of the device to call (Bob) that I spoke when I said "call Bob".

Now that we know which device to place a call to, we can invoke the placeCall method. But because the placeCall method needs an interaction URI, that means we need to start an interaction. So in the CALL_START_REQUEST handler, lets start an interaction on the device that needs to place the call.

Once that interaction is started, we can now invoke placeCall with the destination parameter of Bob, which was passed to us in the CALL_START_REQUEST event. We need to wait until the interaction handler to place the call because the CALL_START_REQUEST event immediately follows the START event, before the interaction has a chance to get created.

Let's see what the code for that looks like thus far:

...
interaction_name = "outbound_interaction"
outbound_device = None
my_interaction_uri = None
destination_uri = None


@mywf.on_start
async def start_handler(workflow, trigger):
    global outbound_device
    outbound_device = workflow.get_source_uri_from_trigger(trigger)


@mywf.on_call_start_request
async def call_start_request_handler(workflow, destination):
    global destination_uri
    destination_uri = destination
    target = workflow.targets_from_source_uri(outbound_device)
    await workflow.start_interaction(target, interaction_name)


@mywf.on_interaction_lifecycle
async def interaction_handler(workflow, itype, interaction_uri, reason):
    if itype == relay.workflow.TYPE_STARTED:
        global my_interaction_uri
        my_interaction_uri = interaction_uri
        await workflow.say_and_wait(my_interaction_uri, "calling from dispatch")
        await workflow.place_call(my_interaction_uri, destination_uri)
    elif itype == relay.workflow.TYPE_ENDED:
        await workflow.terminate()
...
...
        private string interactionName = "answering_interaction";
        private string outboundDevice = null;
        private string myInteractionUri = null;
        private string destinationUri = null;


        public CallingOutboundWf(Relay relay) : base(relay)
        {
        }

        public override void OnStart(IDictionary<string, object> startEvent)
        {
            outboundDevice = Relay.GetSourceUriFromStartEvent(startEvent);
        }

        public override void OnCallStartRequest(IDictionary<string, object> dictionary) {
            destinationUri = (string) dictionary["uri"];
            Relay.StartInteraction(this, outboundDevice, interactionName);            
        }

        public override async void OnInteractionLifecycle(IDictionary<string, object> lifecycleEvent)
        {
            var type = (string) lifecycleEvent["type"];
            
            if (type == InteractionLifecycleType.Started)
            {
                myInteractionUri = (string) lifecycleEvent["source_uri"];
                await Relay.SayAndWait(this, myInteractionUri, "calling from dispatch");
                await Relay.PlaceCall(this, myInteractionUri, destinationUri);
            }
            else if (type == InteractionLifecycleType.Ended)
            {
                Relay.Terminate(this);
            }
        }
...
...
public class CallingOutboundWf {
    public static String interactionName = "outbound_interaction";
    public static String outBoundDevice;
    public static String myInteractionUri;
    public static String destinationUri;
  
    public static void main(String... args) {
        int port = 8080;
        Map<String, String> env = System.getenv();
        try {
            if (env.containsKey("PORT") && (Integer.parseInt(env.get("PORT")) > 0)) {
                port = Integer.parseInt(env.get("PORT"));
            }
        } catch (NumberFormatException e) {
            System.err.println("Unable to parse PORT env value as an integer, ignoring: " + env.get("PORT"));
        }
        Relay.addWorkflow("callpath", new MyWorkflow());
        JettyWebsocketServer.startServer(port);
    }

    private static class MyWorkflow extends Workflow {

        @Override
        public void onStart(Relay relay, StartEvent startEvent) {
            super.onStart(relay, startEvent);

            outBoundDevice = Relay.getSourceUriFromStartEvent(startEvent);
        }

        @Override
        public void onCallStartRequest(Relay relay, CallStartEvent callStartEvent) {
            destinationUri = callStartEvent.uri;
            relay.startInteraction(outBoundDevice, interactionName);
        }

        @Override
        public void onInteractionLifecycle(Relay relay, InteractionLifecycleEvent lifecycleEvent) {
            super.onInteractionLifecycle(relay, lifecycleEvent);            
            String interactionUri = lifecycleEvent.sourceUri;
            
            if (lifecycleEvent.isTypeStarted()) {
                myInteractionUri = interactionUri;
                relay.sayAndWait(myInteractionUri, "calling from dispatch");
                relay.placeCall(myInteractionUri, destinationUri);
            }
            if (lifecycleEvent.isTypeEnded()) {
                relay.terminate();
            }
        }
    }
}
...
...
  const interactionName = `outbound_interaction`
  var destinationUri = null
  var myInteractionUri  = null
  var outBoundDevice = null

  workflow.on(Event.START, async (event) => {
    const { trigger: { args: { source_uri } } } = event
    outBoundDevice = source_uri
  })

  workflow.on(Event.CALL_START_REQUEST, async({destination}) => {
    destinationUri = destination
    await workflow.startInteraction(outBoundDevice, interactionName)
  })

  workflow.on(Event.INTERACTION_STARTED, async ({ source_uri }) => {
    myInteractionUri = source_uri
    await workflow.sayAndWait(myInteractionUri, "calling from dispatch")
    await workflow.placeCall(myInteractionUri, destinationUri)
  })

  workflow.on(Event.INTERACTION_ENDED, async() => {
    await workflow.terminate()
  })
...
...
var interactionName string = "outbount_interaction"
var destinationUri string
var myInteractionUri string
var outBoundDevice string

api.OnStart(func(startEvent sdk.StartEvent) {
  outBoundDevice = api.GetSourceUri(startEvent)
})

api.OnCallStartRequest(func(callStartEvent sdk.CallStartEvent) {
  destinationUri = callStartEvent.Uri
  api.StartInteraction(outBoundDevice, interactionName)
})

api.OnInteractionLifecycle(func(interactionLifecycleEvent sdk.InteractionLifecycleEvent) {

  if interactionLifecycleEvent.LifecycleType == "started" {
    myInteractionUri = interactionLifecycleEvent.SourceUri
    api.SayAndWait(myInteractionUri, "Calling from dispatch", sdk.ENGLISH)
    api.PlaceCall(myInteractionUri, destinationUri)
  }

  if interactionLifecycleEvent.LifecycleType == "ended" {
    api.Terminate()
  }
})
...

In response to us invoking placeCall, we will likely receive a CALL_RINGING event, indicating that the receiving device is ringing, which means it is connected and online. You don't need to do anything with this event, it's informational, unless you want to provide a user experience for it, in our example we'll say "ring ring".

If the receiver answers the call, we will receive a CALL_CONNECTED event. At that point the full duplex audio should be up and running. You don't have to do anything here, but in our example we'll say "connected".

Let's add those handlers for these to our workflow:

...
@mywf.on_call_ringing
async def ringing_handler(workflow, call_id, direction, device_id, device_name,
                          uri, onnet, start_time_epoch):
    await workflow.say_and_wait(my_interaction_uri, "ring ring")


@mywf.on_call_connected
async def connected_handler(workflow, call_id, direction, device_id, device_name,
                           uri, onnet, start_time_epoch, connect_time_epoch):
    await workflow.say_and_wait(my_interaction_uri, "connected")
...
...
        public override async void OnCallRinging(IDictionary<string, object> dictionary)
        {
            await Relay.SayAndWait(this, myInteractionUri, "ring ring");
        }

        public override async void OnCallConnected(IDictionary<string, object> dictionary)
        {
            await Relay.SayAndWait(this, myInteractionUri, "connected");
        }
...
...
  @Override
  public void onCallRinging(Relay relay, CallRingingEvent callRingingEvent) {
    relay.sayAndWait(myInteractionUri, "ring ring");
  }

  @Override
  public void onCallConnected(Relay relay, CallConnectedEvent callConnectedEvent) {
  	relay.sayAndWait(myInteractionUri, "connected");
  }
...
...  
	workflow.on(Event.CALL_RINGING, async({ call_id, direction, device_id, device_name,
                                         uri, onnet, start_time_epoch }) => {
    await workflow.sayAndWait(myInteractionUri, "ring ring")
  })

  workflow.on(Event.CALL_CONNECTED, async({ call_id, direction, device_id, device_name,
                                           uri, onnet, start_time_epoch, connect_time_epoch }) => {
    await workflow.sayAndWait(myInteractionUri, "connected")
  })
...
...
api.OnCallRinging(func(callRingingEvent sdk.CallRingingEvent) {
  api.SayAndWait(myInteractionUri, "ring ring", "en-US")
})

api.OnCallConnected(func(callConnectedEvent sdk.CallConnectedEvent) {
  api.SayAndWait(myInteractionUri, "connected", sdk.ENGLISH)
})
...

Lastly, we'll need to handle the termination of the call, and events that aren't on the happy path.

Some point after the call has been successfully established, one of the parties will hang up. That will send a CALL_DISCONNECTED event to our workflow. When we see that event, we should terminate the workflow.

In the case that the receiver is not online, or rejects the call, that will send a CALL_FAILED event to our workflow. When this happens, we should terminate the workflow.

Let's add these handlers to our workflow:

...
@mywf.on_call_failed
async def failed(workflow, call_id, direction, device_id, device_name,
                uri, on_net, reason, start_time_epoch,
                connect_time_epoch, end_time_epoch):
    await workflow.terminate()


@mywf.on_call_disconnected
async def disconnected_handler(workflow, call_id, direction, device_id, device_name,
                               uri, onnet, reason, start_time_epoch,
                               connect_time_epoch, end_time_epoch):
    print('seconds connected: ' + str(end_time_epoch - start_time_epoch))
    await workflow.end_interaction(outbound_device)
...
...
        public override void OnCallFailed(IDictionary<string, object> dictionary)
        {
            Relay.Terminate(this);
        }

        public override void OnCallDisconnected(IDictionary<string, object> dictionary)
        {
            Log.Information("seconds connected: {0}", (System.Int64) dictionary["end_time_epoch"] - (System.Int64) dictionary["start_time_epoch"]);
            Relay.Terminate(this);
        }
...
...
  @Override
  public void onCallFailed(Relay relay, CallFailedEvent callFailedEvent) {
      relay.terminate();
  }

  @Override
  public void onCallDisconnected(Relay relay, CallDisconnectedEvent callDisconnectedEvent) {
      int secondsConnected = Integer.parseInt(callDisconnectedEvent.endTimeEpoch) - Integer.parseInt(callDisconnectedEvent.startTimeEpoch);
      logger.info("seconds connected: "  + Integer.toString(secondsConnected));
      relay.terminate();
  }
...
...
  workflow.on(Event.CALL_FAILED, async({ call_id, direction, device_id, device_name,
                                        uri, on_net, reason, start_time_epoch, connect_time_epoch, end_time_epoch }) => {
  	workflow.terminate()
  })

  workflow.on(Event.CALL_DISCONNECTED, async({ call_id, direction, device_id, device_name,
                                              uri, onnet, reason, start_time_epoch, connect_time_epoch, end_time_epoch }) => {
    var secondsConnected = parseInt(end_time_epoch) - parseInt(start_time_epoch)
    debug(`seconds connected: ${secondsConnected}`)
    workflow.terminate()
  })
...
...
api.OnCallFailed(func(callFailedEvent sdk.CallFailedEvent) {
  api.Terminate()
})

api.OnCallDisconnected(func(callDisconnectedEvent sdk.CallDisconnectedEvent) {
  fmt.Println("Seconds Connected: " + string(callDisconnectedEvent.EndTimeEpoch - callDisconnectedEvent.StartTimeEpoch))
  api.Terminate()
})
...

Alternate Triggers for Outbound Calls

In the section above we started a call using a built-in trigger phrase of "call Bob". The placeCall API in the SDK can start a call programmatically, so a call can be started from other means, whether it is a different kind of trigger of your own choosing, or perhaps even an external event from a 3rd-party system. In this example, we'll define a trigger of a double-tap of the Talk button while on the Assistant channel.

relay workflow create button --trigger double --uri wss://myhost.mydomain.com/placecallpath --name place-call --install-all

When you do this, you are also replacing the default system workflow for outbound calls, as we did in the section above. So that means your custom workflow becomes responsible for all the user experience elements, just like in the section above.

However, because it wasn't started via the "Call Bob" command to the assistant, a CALL_START_REQUEST event will not be fired. So you'll want to invoke startInteraction in your START handler, and then in the INTERACTION_LIFECYCLE handler you can invoke placeCall because now you have an interaction URI. Otherwise it is the same as the section above.

Now you know how to customize calling!