diff --git a/lib/wp/si-interfaces.c b/lib/wp/si-interfaces.c
index 3b6e737b738695f8fb0513324a2dc1aeb11a342c..fa04b8d5c7401514e139328c5430279496362d96 100644
--- a/lib/wp/si-interfaces.c
+++ b/lib/wp/si-interfaces.c
@@ -100,6 +100,24 @@ wp_si_endpoint_get_stream (WpSiEndpoint * self, guint index)
   return WP_SI_ENDPOINT_GET_IFACE (self)->get_stream (self, index);
 }
 
+/**
+ * wp_si_endpoint_get_stream_acquisition: (virtual get_stream_acquisition)
+ * @self: the session item
+ *
+ * Returns: (transfer none): the stream acquisition interface associated with
+ *   this endpoint, or %NULL if this endpoint does not require acquiring
+ *   streams before linking them
+ */
+WpSiStreamAcquisition *
+wp_si_endpoint_get_stream_acquisition (WpSiEndpoint * self)
+{
+  g_return_val_if_fail (WP_IS_SI_ENDPOINT (self), NULL);
+  g_return_val_if_fail (WP_SI_ENDPOINT_GET_IFACE (self)->get_stream_acquisition,
+      NULL);
+
+  return WP_SI_ENDPOINT_GET_IFACE (self)->get_stream_acquisition (self);
+}
+
 /**
  * WpSiMultiEndpoint:
  *
@@ -296,3 +314,161 @@ wp_si_link_get_in_stream (WpSiLink * self)
 
   return WP_SI_LINK_GET_IFACE (self)->get_in_stream (self);
 }
+
+/**
+ * WpSiPortInfo:
+ *
+ * An interface for retrieving PipeWire port information from a session item.
+ * This information is used to create links in the nodes graph.
+ *
+ * This is normally implemented by the same session items that implement
+ * #WpSiStream. The standard link implementation expects to be able to cast
+ * a #WpSiStream into a #WpSiPortInfo.
+ */
+G_DEFINE_INTERFACE (WpSiPortInfo, wp_si_port_info, WP_TYPE_SESSION_ITEM)
+
+static void
+wp_si_port_info_default_init (WpSiPortInfoInterface * iface)
+{
+}
+
+/**
+ * wp_si_port_info_get_ports: (virtual get_ports)
+ * @self: the session item
+ * @context: (nullable): an optional context for the ports
+ *
+ * This method returns a variant of type "a(uuu)", where each tuple in the
+ * array contains the following information:
+ *   - u: (guint32) node id
+ *   - u: (guint32) port id (the port must belong on the node specified above)
+ *   - u: (guint32) the audio channel (enum spa_audio_channel) that this port
+ *        makes available, or 0 for non-audio content
+ *
+ * The order in which ports appear in this array is important when no channel
+ * information is available. The link implementation should link the ports
+ * in the order they appear. This is normally a good enough substitute for
+ * channel matching.
+ *
+ * The @context argument can be used to get different sets of ports from
+ * the item. The following well-known contexts are defined:
+ *   - %NULL: get the standard ports to be linked
+ *   - "monitor": get the monitor ports
+ *   - "control": get the control port
+ *   - "reverse": get the reverse direction ports, if this item controls a
+ *                filter node, which would have ports on both directions
+ *
+ * Contexts other than %NULL may only be used internally to ease the
+ * implementation of more complex endpoint relationships. For example, a
+ * #WpSessionItem that is in control of an input (sink) adapter node may
+ * implement #WpSiStream and #WpSiPortInfo where the %NULL context will return
+ * the standard input ports and the "monitor" context will return the adapter's
+ * monitor ports. When linking this stream to another stream, the %NULL context
+ * will always be used, but the item may internally spawn a secondary
+ * #WpSessionItem that implements the "monitor" endpoint & stream. That
+ * secondary stream may implement #WpSiPortInfo, chaining calls to the
+ * #WpSiPortInfo of the original item using the "monitor" context. This way,
+ * the monitor #WpSessionItem does not need to share control of the underlying
+ * node; it only proxies calls to satisfy the API.
+ *
+ * Returns: (transfer full): a #GVariant containing information about the
+ *   ports of this item
+ */
+GVariant *
+wp_si_port_info_get_ports (WpSiPortInfo * self, const gchar * context)
+{
+  g_return_val_if_fail (WP_IS_SI_PORT_INFO (self), NULL);
+  g_return_val_if_fail (WP_SI_PORT_INFO_GET_IFACE (self)->get_ports, NULL);
+
+  return WP_SI_PORT_INFO_GET_IFACE (self)->get_ports (self, context);
+}
+
+/**
+ * WpSiStreamAcquisition:
+ *
+ * This interface provides a way to request a stream for linking before doing
+ * so. This allows endpoint implementations to apply internal policy rules
+ * (such as, streams that can only be linked once or mutually exclusive streams).
+ *
+ * A #WpSiStreamAcquisition is associated directly with a #WpSiEndpoint via
+ * wp_si_endpoint_get_stream_acquisition(). In order to allow switching policies,
+ * it is recommended that endpoint implementations use a separate session item
+ * to implement this interface and allow replacing it.
+ */
+G_DEFINE_INTERFACE (WpSiStreamAcquisition, wp_si_stream_acquisition,
+                    WP_TYPE_SESSION_ITEM)
+
+static void
+wp_si_stream_acquisition_default_init (WpSiStreamAcquisitionInterface * iface)
+{
+}
+
+/**
+ * wp_si_stream_acquisition_acquire: (virtual acquire)
+ * @self: the session item
+ * @acquisitor: the link that is trying to acquire a stream
+ * @stream: the stream that is being acquired
+ * @callback: (scope async): the callback to call when the operation is done
+ * @data: (closure): user data for @callback
+ *
+ * Acquires the @stream for linking by @acquisitor.
+ *
+ * When a link is not allowed by policy, this operation should return
+ * an error.
+ *
+ * When a link needs to be delayed for a short amount of time (ex. to apply
+ * a fade out effect on another stream), this operation should finish with a
+ * delay. It is safe to assume that after this operation completes,
+ * the stream will be linked immediately.
+ */
+void
+wp_si_stream_acquisition_acquire (WpSiStreamAcquisition * self,
+    WpSiLink * acquisitor, WpSiStream * stream,
+    GAsyncReadyCallback callback, gpointer data)
+{
+  g_return_if_fail (WP_IS_SI_STREAM_ACQUISITION (self));
+  g_return_if_fail (WP_SI_STREAM_ACQUISITION_GET_IFACE (self)->acquire);
+
+  WP_SI_STREAM_ACQUISITION_GET_IFACE (self)->acquire (self, acquisitor, stream,
+      callback, data);
+}
+
+/**
+ * wp_si_stream_acquisition_acquire_finish: (virtual acquire_finish)
+ * @self: the session item
+ * @res: the async result
+ * @error: (out) (optional): the operation's error, if it occurred
+ *
+ * Finishes the operation started by wp_si_stream_acquisition_acquire().
+ * This is meant to be called in the callback that was passed to that method.
+ *
+ * Returns: %TRUE on success, %FALSE if there was an error
+ */
+gboolean
+wp_si_stream_acquisition_acquire_finish (WpSiStreamAcquisition * self,
+    GAsyncResult * res, GError ** error)
+{
+  g_return_val_if_fail (WP_IS_SI_STREAM_ACQUISITION (self), FALSE);
+  g_return_val_if_fail (
+      WP_SI_STREAM_ACQUISITION_GET_IFACE (self)->acquire_finish, FALSE);
+
+  return WP_SI_STREAM_ACQUISITION_GET_IFACE (self)->acquire_finish (self, res,
+      error);
+}
+
+/**
+ * wp_si_stream_acquisition_release: (virtual release)
+ * @self: the session item
+ * @acquisitor: the link that had previously acquired the stream
+ * @stream: the stream that is being released
+ *
+ * Releases the @stream, which means that it is being unlinked.
+ */
+void
+wp_si_stream_acquisition_release (WpSiStreamAcquisition * self,
+    WpSiLink * acquisitor, WpSiStream * stream)
+{
+  g_return_if_fail (WP_IS_SI_STREAM_ACQUISITION (self));
+  g_return_if_fail (WP_SI_STREAM_ACQUISITION_GET_IFACE (self)->release);
+
+  WP_SI_STREAM_ACQUISITION_GET_IFACE (self)->release (self, acquisitor, stream);
+}
diff --git a/lib/wp/si-interfaces.h b/lib/wp/si-interfaces.h
index d16289999f9324aa8f1c3e16d65b21465a635dca..44b115dea7c281fd161d2068dd28e3dcbdd95eba 100644
--- a/lib/wp/si-interfaces.h
+++ b/lib/wp/si-interfaces.h
@@ -17,6 +17,7 @@
 G_BEGIN_DECLS
 
 typedef struct _WpSiStream WpSiStream;
+typedef struct _WpSiStreamAcquisition WpSiStreamAcquisition;
 
 /**
  * WP_TYPE_SI_ENDPOINT:
@@ -37,6 +38,8 @@ struct _WpSiEndpointInterface
 
   guint (*get_n_streams) (WpSiEndpoint * self);
   WpSiStream * (*get_stream) (WpSiEndpoint * self, guint index);
+
+  WpSiStreamAcquisition * (*get_stream_acquisition) (WpSiEndpoint * self);
 };
 
 WP_API
@@ -51,6 +54,9 @@ guint wp_si_endpoint_get_n_streams (WpSiEndpoint * self);
 WP_API
 WpSiStream * wp_si_endpoint_get_stream (WpSiEndpoint * self, guint index);
 
+WP_API
+WpSiStreamAcquisition * wp_si_endpoint_get_stream_acquisition (WpSiEndpoint * self);
+
 /**
  * WP_TYPE_SI_MULTI_ENDPOINT:
  *
@@ -138,6 +144,62 @@ WpSiStream * wp_si_link_get_out_stream (WpSiLink * self);
 WP_API
 WpSiStream * wp_si_link_get_in_stream (WpSiLink * self);
 
+/**
+ * WP_TYPE_SI_PORT_INFO:
+ *
+ * The #WpSiPortInfo #GType
+ */
+#define WP_TYPE_SI_PORT_INFO (wp_si_port_info_get_type ())
+WP_API
+G_DECLARE_INTERFACE (WpSiPortInfo, wp_si_port_info,
+                     WP, SI_PORT_INFO, WpSessionItem)
+
+struct _WpSiPortInfoInterface
+{
+  GTypeInterface interface;
+
+  GVariant * (*get_ports) (WpSiPortInfo * self, const gchar * context);
+};
+
+WP_API
+GVariant * wp_si_port_info_get_ports (WpSiPortInfo * self,
+    const gchar * context);
+
+/**
+ * WP_TYPE_SI_STREAM_ACQUISITION:
+ *
+ * The #WpSiStreamAcquisition #GType
+ */
+#define WP_TYPE_SI_STREAM_ACQUISITION (wp_si_stream_acquisition_get_type ())
+WP_API
+G_DECLARE_INTERFACE (WpSiStreamAcquisition, wp_si_stream_acquisition,
+                     WP, SI_STREAM_ACQUISITION, WpSessionItem)
+
+struct _WpSiStreamAcquisitionInterface
+{
+  GTypeInterface interface;
+
+  void (*acquire) (WpSiStreamAcquisition * self, WpSiLink * acquisitor,
+      WpSiStream * stream, GAsyncReadyCallback callback, gpointer data);
+  gboolean (*acquire_finish) (WpSiStreamAcquisition * self,
+      GAsyncResult * res, GError ** error);
+
+  void (*release) (WpSiStreamAcquisition * self, WpSiLink * acquisitor,
+      WpSiStream * stream);
+};
+
+WP_API
+void wp_si_stream_acquisition_acquire (WpSiStreamAcquisition * self,
+    WpSiLink * acquisitor, WpSiStream * stream,
+    GAsyncReadyCallback callback, gpointer data);
+
+WP_API
+gboolean wp_si_stream_acquisition_acquire_finish (WpSiStreamAcquisition * self,
+    GAsyncResult * res, GError ** error);
+
+WP_API
+void wp_si_stream_acquisition_release (WpSiStreamAcquisition * self,
+    WpSiLink * acquisitor, WpSiStream * stream);
 
 G_END_DECLS
 
diff --git a/modules/meson.build b/modules/meson.build
index 3fe9c1cd193826958cfafe7137931a8d013dd084..edab10f80d16becdedb32ddd2e45654d9c235319 100644
--- a/modules/meson.build
+++ b/modules/meson.build
@@ -114,3 +114,14 @@ shared_library(
   install_dir : wireplumber_module_dir,
   dependencies : [wp_dep, pipewire_dep],
 )
+
+shared_library(
+  'wireplumber-module-si-standard-link',
+  [
+    'module-si-standard-link.c',
+  ],
+  c_args : [common_c_args, '-DG_LOG_DOMAIN="m-si-standard-link"'],
+  install : true,
+  install_dir : wireplumber_module_dir,
+  dependencies : [wp_dep, pipewire_dep],
+)
diff --git a/modules/module-si-standard-link.c b/modules/module-si-standard-link.c
new file mode 100644
index 0000000000000000000000000000000000000000..973cee37466f69a259b92700fec1bde6fc9a4f1f
--- /dev/null
+++ b/modules/module-si-standard-link.c
@@ -0,0 +1,474 @@
+/* WirePlumber
+ *
+ * Copyright © 2020 Collabora Ltd.
+ *    @author George Kiagiadakis <george.kiagiadakis@collabora.com>
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+#include <wp/wp.h>
+#include <pipewire/pipewire.h>
+#include <spa/debug/types.h>
+#include <spa/param/audio/type-info.h>
+
+enum {
+  STEP_ACQUIRE = WP_TRANSITION_STEP_CUSTOM_START,
+  STEP_LINK,
+};
+
+struct _WpSiStandardLink
+{
+  WpSessionItem parent;
+
+  WpSiStream *out_stream;
+  WpSiStream *in_stream;
+
+  GPtrArray *node_links;
+  guint n_async_ops_wait;
+};
+
+static void si_standard_link_link_init (WpSiLinkInterface * iface);
+
+G_DECLARE_FINAL_TYPE (WpSiStandardLink, si_standard_link, WP, SI_STANDARD_LINK, WpSessionItem)
+G_DEFINE_TYPE_WITH_CODE (WpSiStandardLink, si_standard_link, WP_TYPE_SESSION_ITEM,
+    G_IMPLEMENT_INTERFACE (WP_TYPE_SI_LINK, si_standard_link_link_init))
+
+static void
+on_stream_destroyed (gpointer data, GObject * stream)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (data);
+
+  if ((gpointer) self->out_stream == (gpointer) stream)
+    self->out_stream = NULL;
+  else if ((gpointer) self->in_stream == (gpointer) stream)
+    self->in_stream = NULL;
+
+  wp_session_item_reset (WP_SESSION_ITEM (self));
+}
+
+static void
+on_stream_flags_changed (WpSessionItem * stream, WpSiFlags flags,
+    WpSiStandardLink *self)
+{
+  /* stream was deactivated; treat it as destroyed and reset */
+  if (!(flags & WP_SI_FLAG_ACTIVE))
+    wp_session_item_reset (WP_SESSION_ITEM (self));
+}
+
+static inline void
+disconnect_stream (WpSiStandardLink *self, WpSiStream * stream)
+{
+  if (stream) {
+    g_signal_handlers_disconnect_by_data (stream, self);
+    g_object_weak_unref (G_OBJECT (stream), on_stream_destroyed, self);
+  }
+}
+
+static void
+si_standard_link_init (WpSiStandardLink * self)
+{
+}
+
+static void
+si_standard_link_reset (WpSessionItem * item)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+
+  WP_SESSION_ITEM_CLASS (si_standard_link_parent_class)->reset (item);
+
+  disconnect_stream (self, self->out_stream);
+  disconnect_stream (self, self->in_stream);
+  self->out_stream = NULL;
+  self->in_stream = NULL;
+
+  wp_session_item_clear_flag (item, WP_SI_FLAG_CONFIGURED);
+}
+
+static GVariant *
+si_standard_link_get_configuration (WpSessionItem * item)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+  GVariantBuilder b;
+
+  /* Set the properties */
+  g_variant_builder_init (&b, G_VARIANT_TYPE_VARDICT);
+  g_variant_builder_add (&b, "{sv}",
+      "out-stream", g_variant_new_uint64 ((guint64) self->out_stream));
+  g_variant_builder_add (&b, "{sv}",
+      "in-stream", g_variant_new_uint64 ((guint64) self->in_stream));
+  return g_variant_builder_end (&b);
+}
+
+static gboolean
+si_standard_link_configure (WpSessionItem * item, GVariant * args)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+  guint64 out_stream_i, in_stream_i;
+  WpSessionItem *out_stream, *in_stream;
+
+  if (wp_session_item_get_flags (item) &
+          (WP_SI_FLAG_ACTIVATING | WP_SI_FLAG_ACTIVE |
+           WP_SI_FLAG_EXPORTING | WP_SI_FLAG_EXPORTED))
+    return FALSE;
+
+  if (!g_variant_lookup (args, "out-stream", "t", &out_stream_i) ||
+      !g_variant_lookup (args, "in-stream", "t", &in_stream_i))
+    return FALSE;
+
+  out_stream = GUINT_TO_POINTER (out_stream_i);
+  in_stream = GUINT_TO_POINTER (in_stream_i);
+
+  if (!WP_IS_SI_STREAM (out_stream) || !WP_IS_SI_STREAM (in_stream) ||
+      !WP_IS_SI_PORT_INFO (out_stream) || !WP_IS_SI_PORT_INFO (in_stream) ||
+      !(wp_session_item_get_flags (out_stream) & WP_SI_FLAG_ACTIVE) ||
+      !(wp_session_item_get_flags (in_stream) & WP_SI_FLAG_ACTIVE))
+    return FALSE;
+
+  disconnect_stream (self, self->out_stream);
+  disconnect_stream (self, self->in_stream);
+
+  self->out_stream = WP_SI_STREAM (out_stream);
+  self->in_stream = WP_SI_STREAM (in_stream);
+
+  g_signal_connect_object (self->out_stream, "flags-changed",
+      G_CALLBACK (on_stream_flags_changed), self, 0);
+  g_signal_connect_object (self->in_stream, "flags-changed",
+      G_CALLBACK (on_stream_flags_changed), self, 0);
+  g_object_weak_ref (G_OBJECT (self->out_stream), on_stream_destroyed, self);
+  g_object_weak_ref (G_OBJECT (self->in_stream), on_stream_destroyed, self);
+
+  wp_session_item_set_flag (item, WP_SI_FLAG_CONFIGURED);
+
+  return TRUE;
+}
+
+static guint
+si_standard_link_get_next_step (WpSessionItem * item,
+     WpTransition * transition, guint step)
+{
+  WpSiStandardLink *self = wp_transition_get_source_object (transition);
+
+  switch (step) {
+    case WP_TRANSITION_STEP_NONE:
+      return STEP_ACQUIRE;
+
+    case STEP_ACQUIRE:
+      if (self->n_async_ops_wait == 0)
+        return STEP_LINK;
+      else
+        return step;
+
+    case STEP_LINK:
+      if (self->n_async_ops_wait == 0)
+        return WP_TRANSITION_STEP_NONE;
+      else
+        return step;
+
+    default:
+      return WP_TRANSITION_STEP_ERROR;
+  }
+}
+
+static void
+on_stream_acquired (WpSiStreamAcquisition * acq, GAsyncResult * res,
+    WpTransition * transition)
+{
+  WpSiStandardLink *self = wp_transition_get_source_object (transition);
+  g_autoptr (GError) error = NULL;
+
+  if (!wp_si_stream_acquisition_acquire_finish (acq, res, &error)) {
+    wp_transition_return_error (transition, g_steal_pointer (&error));
+    return;
+  }
+
+  self->n_async_ops_wait--;
+  wp_transition_advance (transition);
+}
+
+static void
+on_link_augmented (WpProxy * proxy, GAsyncResult * res,
+    WpTransition * transition)
+{
+  WpSiStandardLink *self = wp_transition_get_source_object (transition);
+  g_autoptr (GError) error = NULL;
+
+  if (!wp_proxy_augment_finish (proxy, res, &error)) {
+    wp_transition_return_error (transition, g_steal_pointer (&error));
+    return;
+  }
+
+  self->n_async_ops_wait--;
+  wp_transition_advance (transition);
+}
+
+static WpCore *
+find_core (WpSiStandardLink * self)
+{
+  /* session items are not associated with a core, but surely when linking
+    we should be able to find a WpImplEndpointLink associated, or at the very
+    least a WpEndpoint associated with one of the streams... */
+  g_autoptr (WpProxy) proxy = wp_session_item_get_associated_proxy (
+      WP_SESSION_ITEM (self), WP_TYPE_ENDPOINT_LINK);
+  if (!proxy) {
+      proxy = wp_session_item_get_associated_proxy (
+          WP_SESSION_ITEM (self->out_stream), WP_TYPE_ENDPOINT);
+  }
+  return proxy ? wp_proxy_get_core (proxy) : NULL;
+}
+
+static gboolean
+create_links (WpSiStandardLink * self, GVariant * out_ports, GVariant * in_ports)
+{
+  g_autoptr (GPtrArray) in_ports_arr = NULL;
+  g_autoptr (WpCore) core = NULL;
+  WpLink *link;
+  GVariantIter *iter;
+  GVariant *child;
+  guint32 out_node_id, in_node_id;
+  guint32 out_port_id, in_port_id;
+  guint32 out_channel, in_channel;
+  gboolean link_all = FALSE;
+  guint i;
+
+  /* tuple format:
+      uint32 node_id;
+      uint32 port_id;
+      uint32 channel;  // enum spa_audio_channel
+   */
+  if (!g_variant_is_of_type (out_ports, G_VARIANT_TYPE("a(uuu)")))
+    return FALSE;
+  if (!g_variant_is_of_type (in_ports, G_VARIANT_TYPE("a(uuu)")))
+    return FALSE;
+
+  core = find_core (self);
+  g_return_val_if_fail (core, FALSE);
+
+  self->n_async_ops_wait = 0;
+  self->node_links = g_ptr_array_new_with_free_func (g_object_unref);
+
+  /* transfer the in ports to an array so that we can
+     delete them when they are linked */
+  i = g_variant_n_children (in_ports);
+  in_ports_arr = g_ptr_array_new_full (i, (GDestroyNotify) g_variant_unref);
+  g_ptr_array_set_size (in_ports_arr, i);
+
+  g_variant_get (in_ports, "a(uuu)", &iter);
+  while ((child = g_variant_iter_next_value (iter)))
+    g_ptr_array_insert (in_ports_arr, --i, child);
+  g_variant_iter_free (iter);
+
+  /* now loop over the out ports and figure out where they should be linked */
+  g_variant_get (out_ports, "a(uuu)", &iter);
+
+  /* special case for mono inputs: link to all outputs,
+     since we don't support proper channel mapping yet */
+  if (g_variant_iter_n_children (iter) == 1)
+    link_all = TRUE;
+
+  while (g_variant_iter_loop (iter, "(uuu)", &out_node_id, &out_port_id,
+              &out_channel))
+  {
+    for (i = in_ports_arr->len; i > 0; i--) {
+      child = g_ptr_array_index (in_ports_arr, i - 1);
+      g_variant_get (child, "(uuu)", &in_node_id, &in_port_id, &in_channel);
+
+      /* the channel has to match, unless we don't have any information
+         on channel ordering on either side */
+      if (link_all ||
+          out_channel == in_channel ||
+          out_channel == SPA_AUDIO_CHANNEL_UNKNOWN ||
+          in_channel == SPA_AUDIO_CHANNEL_UNKNOWN)
+      {
+        g_autoptr (WpProperties) props = NULL;
+
+        /* Create the properties */
+        props = wp_properties_new_empty ();
+        wp_properties_setf (props, PW_KEY_LINK_OUTPUT_NODE, "%u", out_node_id);
+        wp_properties_setf (props, PW_KEY_LINK_OUTPUT_PORT, "%u", out_port_id);
+        wp_properties_setf (props, PW_KEY_LINK_INPUT_NODE, "%u", in_node_id);
+        wp_properties_setf (props, PW_KEY_LINK_INPUT_PORT, "%u", in_port_id);
+
+        g_debug ("Create pw link: %u:%u (%s) -> %u:%u (%s)",
+            out_node_id, out_port_id,
+            spa_debug_type_find_name (spa_type_audio_channel, out_channel),
+            in_node_id, in_port_id,
+            spa_debug_type_find_name (spa_type_audio_channel, in_channel));
+
+        /* create the link */
+        link = wp_link_new_from_factory (core, "link-factory",
+            g_steal_pointer (&props));
+        g_ptr_array_add (self->node_links, link);
+
+        /* augment to ensure it is created without errors */
+        self->n_async_ops_wait++;
+        wp_proxy_augment (WP_PROXY (link), WP_PROXY_FEATURES_STANDARD, NULL,
+            (GAsyncReadyCallback) on_link_augmented, self);
+
+        /* continue to link all input ports, if requested */
+        if (link_all)
+          continue;
+
+        /* remove the linked input port from the array */
+        g_ptr_array_remove_index (in_ports_arr, i - 1);
+
+        /* break out of the for loop; go for the next out port */
+        break;
+      }
+    }
+  }
+  return TRUE;
+}
+
+static void
+si_standard_link_execute_step (WpSessionItem * item, WpTransition * transition,
+    guint step)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+
+  switch (step) {
+  case STEP_ACQUIRE: {
+    WpSiEndpoint *out_endpoint, *in_endpoint;
+    WpSiStreamAcquisition *out_acquisition, *in_acquisition;
+
+    out_endpoint = wp_si_stream_get_parent_endpoint (self->out_stream);
+    in_endpoint = wp_si_stream_get_parent_endpoint (self->in_stream);
+    out_acquisition = wp_si_endpoint_get_stream_acquisition (out_endpoint);
+    in_acquisition = wp_si_endpoint_get_stream_acquisition (in_endpoint);
+
+    if (out_acquisition && in_acquisition)
+      self->n_async_ops_wait = 2;
+    else if (out_acquisition || in_acquisition)
+      self->n_async_ops_wait = 1;
+    else {
+      self->n_async_ops_wait = 0;
+      wp_transition_advance (transition);
+      return;
+    }
+
+    if (out_acquisition) {
+      wp_si_stream_acquisition_acquire (out_acquisition, WP_SI_LINK (self),
+          self->out_stream, (GAsyncReadyCallback) on_stream_acquired,
+          transition);
+    }
+    if (in_acquisition) {
+      wp_si_stream_acquisition_acquire (in_acquisition, WP_SI_LINK (self),
+          self->in_stream, (GAsyncReadyCallback) on_stream_acquired,
+          transition);
+    }
+    break;
+  }
+  case STEP_LINK: {
+    g_autoptr (GVariant) out_ports = NULL;
+    g_autoptr (GVariant) in_ports = NULL;
+
+    out_ports = wp_si_port_info_get_ports (WP_SI_PORT_INFO (self->out_stream),
+        NULL);
+    in_ports = wp_si_port_info_get_ports (WP_SI_PORT_INFO (self->in_stream),
+        NULL);
+
+    if (!create_links (self, out_ports, in_ports)) {
+      wp_transition_return_error (transition, g_error_new (WP_DOMAIN_LIBRARY,
+              WP_LIBRARY_ERROR_INVARIANT,
+              "Bad port info returned from one of the streams"));
+    }
+    break;
+  }
+  default:
+    WP_SESSION_ITEM_GET_CLASS (si_standard_link_parent_class)->execute_step (
+          item, transition, step);
+      break;
+  }
+}
+
+static void
+si_standard_link_deactivate (WpSessionItem * item)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+  WpSiEndpoint *out_endpoint, *in_endpoint;
+  WpSiStreamAcquisition *out_acquisition, *in_acquisition;
+
+  out_endpoint = wp_si_stream_get_parent_endpoint (self->out_stream);
+  in_endpoint = wp_si_stream_get_parent_endpoint (self->in_stream);
+  out_acquisition = wp_si_endpoint_get_stream_acquisition (out_endpoint);
+  in_acquisition = wp_si_endpoint_get_stream_acquisition (in_endpoint);
+
+  if (out_acquisition) {
+    wp_si_stream_acquisition_release (out_acquisition, WP_SI_LINK (self),
+        self->out_stream);
+  }
+  if (in_acquisition) {
+    wp_si_stream_acquisition_release (in_acquisition, WP_SI_LINK (self),
+        self->in_stream);
+  }
+
+  g_clear_pointer (&self->node_links, g_ptr_array_unref);
+
+  WP_SESSION_ITEM_CLASS (si_standard_link_parent_class)->deactivate (item);
+}
+
+static void
+si_standard_link_class_init (WpSiStandardLinkClass * klass)
+{
+  WpSessionItemClass *si_class = (WpSessionItemClass *) klass;
+
+  si_class->reset = si_standard_link_reset;
+  si_class->configure = si_standard_link_configure;
+  si_class->get_configuration = si_standard_link_get_configuration;
+  si_class->get_next_step = si_standard_link_get_next_step;
+  si_class->execute_step = si_standard_link_execute_step;
+  si_class->deactivate = si_standard_link_deactivate;
+}
+
+static GVariant *
+si_standard_link_get_registration_info (WpSiLink * item)
+{
+  GVariantBuilder b;
+  g_variant_builder_init (&b, G_VARIANT_TYPE ("a{ss}"));
+  return g_variant_builder_end (&b);
+}
+
+static WpProperties *
+si_standard_link_get_properties (WpSiLink * item)
+{
+  return wp_properties_new_empty ();
+}
+
+static WpSiStream *
+si_standard_link_get_out_stream (WpSiLink * item)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+  return self->out_stream;
+}
+
+static WpSiStream *
+si_standard_link_get_in_stream (WpSiLink * item)
+{
+  WpSiStandardLink *self = WP_SI_STANDARD_LINK (item);
+  return self->in_stream;
+}
+
+static void
+si_standard_link_link_init (WpSiLinkInterface * iface)
+{
+  iface->get_registration_info = si_standard_link_get_registration_info;
+  iface->get_properties = si_standard_link_get_properties;
+  iface->get_out_stream = si_standard_link_get_out_stream;
+  iface->get_in_stream = si_standard_link_get_in_stream;
+}
+
+WP_PLUGIN_EXPORT void
+wireplumber__module_init (WpModule * module, WpCore * core, GVariant * args)
+{
+  GVariantBuilder b;
+
+  g_variant_builder_init (&b, G_VARIANT_TYPE_VARDICT);
+  g_variant_builder_add (&b, "(ssymv)", "out-stream", "t",
+      WP_SI_CONFIG_OPTION_WRITEABLE | WP_SI_CONFIG_OPTION_REQUIRED, NULL);
+  g_variant_builder_add (&b, "(ssymv)", "in-stream", "t",
+      WP_SI_CONFIG_OPTION_WRITEABLE | WP_SI_CONFIG_OPTION_REQUIRED, NULL);
+
+  wp_si_factory_register (core, wp_si_factory_new_simple (
+          "si-standard-link",
+          si_standard_link_get_type (),
+          g_variant_builder_end (&b)));
+}