Skip to content

Conversation

@Kaian
Copy link

@Kaian Kaian commented Aug 27, 2025

We've been exploring the idea of enabling real attended call transfers from LiveKit rooms, with the first step being support for call holding. When connecting LiveKit Trunks from a PBX, this allows music-on-hold configured in the PBX to be played to the participant.

This PR introduces the initial implementation of call hold and unhold functionality in the LiveKit SIP component. It allows the LiveKit Server to put SIP calls on hold and resume them (unhold), by adding the necessary handling of re-INVITE messages for hold/unhold.

Further PRs will be required in other LiveKit projects to fully implement this functionality across the platform (including livekit, livekit-sdks, livekit-protocol and so on).

We’d love to hear if the team is interested in this functionality and any feedback on the approach to summit additional Pull Requests.

@Kaian Kaian requested a review from a team as a code owner August 27, 2025 09:33
@CLAassistant
Copy link

CLAassistant commented Aug 27, 2025

CLA assistant check
All committers have signed the CLA.

@Kaian Kaian force-pushed the hold-resume-initial-implementation branch 2 times, most recently from 8a4cd77 to 04d94e4 Compare August 27, 2025 10:21
@codecov
Copy link

codecov bot commented Aug 27, 2025

Codecov Report

❌ Patch coverage is 0.99010% with 100 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.68%. Comparing base (0460b40) to head (304ba19).
⚠️ Report is 180 commits behind head on main.

Files with missing lines Patch % Lines
pkg/sip/inbound.go 0.99% 100 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #443      +/-   ##
==========================================
- Coverage   65.25%   63.68%   -1.57%     
==========================================
  Files          51       32      -19     
  Lines        6588     6169     -419     
==========================================
- Hits         4299     3929     -370     
+ Misses       1915     1850      -65     
- Partials      374      390      +16     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@dennwc dennwc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you a lot for contributing the hold/resume functionality! The idea and implementation makes perfect sense. New internal API is the most straightforward way to achieve this.

What I'm wondering about is, would it make sense to hold/unhold automatically when SIP participant looses all audio track subscriptions? In this case, if a person/agent that is in the room suddenly drops, we could indicate a hold for the SIP participant instead of just relaying silence.

This way, there will be no extra API required - existing participant admin API can be used to unsubscribe SIP from existing tracks, move it to another empty room or disconnecting the other peer from the room.

What do you think about this approach?

@Kaian
Copy link
Author

Kaian commented Aug 27, 2025

Hi!

Thanks for the feedback!

We're currently testing this feature in the following scenario: An incoming call is dispatched to a room where an agent can be requested to perform a transfer, first checking whether the destination is available. The agent places the first participant on hold and creates a second participant in the same room by making an outbound call. When this second participant answers, their availability is verified, and based on the response, we remove the second participant from the room and perform a cold transfer from the first participant (or not). This approach has some advantages, such as providing a full transcription or recording of the entire process while only using a single room.

I have little to no experience with LiveKit, so I cannot say what the best approach would be. However, I’m concerned that some providers may not accept in-dialog re-INVITEs the way PBXs do, so making this behavior implicit could cause future issues in other scenarios.

Of course, your opinion is what matters the most, and if you decide to implement it differently, we’ll be more than happy to adapt how our agent works!

Best regards,

@Kaian Kaian force-pushed the hold-resume-initial-implementation branch from 04d94e4 to 8c5ab03 Compare October 2, 2025 14:32
@Kaian
Copy link
Author

Kaian commented Oct 2, 2025

Hi!

Sorry for the long silence, I've been quite busy.

I've been thinking about your proposal, and I understand the point of not changing the API to avoid impacting other modules (SDK, protocol, etc.).

I think at least a Trunk flag will be necessary to mark which providers support receiving an in-dialog re-INVITE, something like holdSupport, disabled by default for inbound/outbound trunks. Media direction would only change for those capable of playing music on hold.

I've rebased and updated the patch by removing some unnecessary code, fixing a problem with SDPVersion not being updated in new messages, and removing all internal API-related code. As a result, the hold/unhold functions are not actually used in this current proposed code. I'm not sure where track subscriptions should be handled to trigger the hold/unhold process (maybe in the LiveKit core project?) so I’ll need some guidance.

Also, regarding the failing tests, I don’t know what the root cause of the problem is and I’m not sure how our changes might have affected them.

Feel free to request any additional information; we’re entirely at your disposal.

Thanks for your time!

@Kaian Kaian force-pushed the hold-resume-initial-implementation branch from 8c5ab03 to 9861143 Compare November 6, 2025 06:21
Copy link
Contributor

@dennwc dennwc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for you patience and for working on this change! We finally have a window to help move it over the finish line.

}
c.mon.SDPSize(len(sdpOfferData), true)
c.log.Debugw("SDP offer", "sdp", string(sdpOfferData))
c.cc.nextSDPVersion = sdpOffer.SDP.Origin.SessionVersion + 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe set it in the cc.Invite?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a dialog-scope value that must be incremented with every SDP update. I saw the c.cc.nextRequestCSeq flag and thought it had a similar purpose.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'm just saying maybe it's a good idea to set it in one of the methods of the object itself as opposed to setting it externally.

var newAttributes []psdp.Attribute
for _, attr := range mediaDesc.Attributes {
// Keep all attributes except direction-related ones
if attr.Key != "sendrecv" && attr.Key != "sendonly" &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use switch so that it's easier to read?

switch attr.Key {
default:
    newAttributes = append(newAttributes, attr)
case "sendrecv","sendonly","recvonly","inactive":
   // skip
}

Or better yet, use slices.DeleteFunc.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done a combination of both

		newAttributes := slices.DeleteFunc(mediaDesc.Attributes, func(attr psdp.Attribute) bool {
			switch attr.Key {
			case "sendrecv", "sendonly", "recvonly", "inactive":
				return true
			default:
				return false
			}
		})

if len(sdpOffer) > 0 {
modifiedSDP, err := c.setMediaDirection(sdpOffer, "sendonly")
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing c.mu.Unlock() here. Maybe add a separate function that creates a request, so that it can defer the unlock?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have moved this to a function that creates the reInvite request

}

func (c *sipInbound) holdCall(ctx context.Context) error {
c.mu.Lock()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want a second mutex here, like holdMu or sdpMu that will prevent multiple calls to hold/unhold.

}

if resp.StatusCode != sip.StatusOK {
return &livekit.SIPStatus{Code: livekit.SIPStatusCode(resp.StatusCode)}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now also extract the reason for these status codes. Maybe worth adding a helper for it at this point.

if len(sdpOffer) > 0 {
modifiedSDP, err := c.setMediaDirection(sdpOffer, "sendrecv")
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same - missing unlock.

}
}

func (c *sipOutbound) setMediaDirection(sdpData []byte, direction string) ([]byte, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it doesn't access anything on c and the code is basically the same as for inbound. Maybe it should be on a MediaPort instead? It would also be easier to test if it works with a media timeout, etc.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the proper place for this would be media-sdk/sdp, that seems to be the place where all SDP changes are made, but that's another project

@Kaian Kaian force-pushed the hold-resume-initial-implementation branch from a672d07 to 304ba19 Compare November 19, 2025 14:42
@Kaian
Copy link
Author

Kaian commented Nov 19, 2025

First of all, thank you for taking the time to review this.

As a reminder, these changes are just a code snippet. By themselves, they don't add any functionality since they aren't called from anywhere yet. The version we have been testing in our fork was creating two API endpoints, but that require updating more livekit projects.

You should know that my Go skills are quite limited, but I have some knowledge of the SIP protocol. Please feel free to reimplement anything as you see fit, I truly value your time and wouldn't want you spending it extensively reviewing a novice's PRs.

I've rewritten part of the code to separate the functionality and have only focused on the inbound dialog (I think this feature will be mostly for holding incoming calls). Based on the resulting code, it should be easier to extrapolate for outgoing dialogs.

Best Regards,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants