Add #ajax event to a custom composite element
Turns out I was pretty close, an external module was causing interference while authenticated (was getting a post max size exceeded error when submitting the form) on the front end. Unrelated to Webform.
You can add ajax to composite elements and react to them in afterBuild like so (or in your own custom form elements, added to the composite):
class WebformTourAvailabilityComposite extends WebformCompositeBase {

  /**
   * {@inheritdoc}
   */
  public static function getCompositeElements(array $element) {
    $date_format_entity = DateFormat::load('html_date');
    $date_format = $date_format_entity-&gt;getPattern();

    $elements = [];

    $elements['tour_date'] = [
      '#type' =&gt; 'date',
      '#title' =&gt; t(&quot;Tour Date&quot;),
      '#date_date_format' =&gt; $date_format,
      '#date_year_range' =&gt; '0:15',
      '#attributes' =&gt; [
        'min' =&gt; '[webform_submission:created:html_date]',
        'onkeydown' =&gt; 'return false'
      ],
      '#datepicker_button' =&gt; TRUE,
      '#date_label_position' =&gt; 'invisible',
      '#required' =&gt; TRUE,
      '#ajax' =&gt; [
        'callback' =&gt; ['\Drupal\mymodule\Element', 'refreshTimes'],
        'wrapper' =&gt; 'available-times'
      ],
      '#prefix' =&gt; '&lt;div id=&quot;available-times&quot;&gt;',
    ];

    $elements['tour_time'] = [
      '#type' =&gt; 'select',
      '#title' =&gt; t('Tour time'),
      '#suffix' =&gt; '&lt;/div&gt;',
      '#after_build' =&gt; [[get_called_class(), 'afterBuild']],
      '#validated' =&gt; TRUE,
    ];

    return $elements;
  }

  public static function refreshTimes($form, FormStateInterface $form_state) {
    return $form['elements']['tour_time'];
  }

  /**
   * Performs the after_build callback.
   */
  public static function afterBuild(array $element, FormStateInterface $form_state) {
    $client = \Drupal::service('mymodule.client');
    $selected = $form_state-&gt;getValues();

    if (isset($selected['tour_date']) &amp;&amp; strlen($selected['tour_date'])) {
      $times = $client-&gt;getAvailableTourTimes($selected[&quot;tour_date&quot;]);
      $element['#options'] = count($times) ? $times : ['' =&gt; '- Select -'];
      $element['#description'] = count($times) ? '' : 'There were no available times found for the date you selected. Please choose another date.';
    }

    return $element;
  }

}

Note that your form keys will vary depending on your form, what you name fields, etc. On afterBuild, in this case, if the date picker has a value submitted, it will call a remote API and replace the options in the select with values.