diff --git a/pose_library/__init__.py b/pose_library/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1105065b44a34ef98c37775bfa0f739d8a1a2a72
--- /dev/null
+++ b/pose_library/__init__.py
@@ -0,0 +1,81 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library based on the Asset Browser.
+"""
+
+bl_info = {
+    "name": "Pose Library",
+    "description": "Pose Library based on the Asset Browser.",
+    "author": "Sybren A. Stüvel",
+    "version": (2, 0),
+    "blender": (3, 0, 0),
+    "warning": "In heavily development, things may change",
+    "location": "Asset Browser -> Animations, and 3D Viewport -> Animation panel",
+    # "doc_url": "{BLENDER_MANUAL_URL}/addons/animation/pose_library.html",
+    "support": "OFFICIAL",
+    "category": "Animation",
+}
+
+from typing import List, Tuple
+
+_need_reload = "operators" in locals()
+from . import gui, keymaps, macros, operators, conversion
+
+if _need_reload:
+    import importlib
+
+    gui = importlib.reload(gui)
+    keymaps = importlib.reload(keymaps)
+    macros = importlib.reload(macros)
+    operators = importlib.reload(operators)
+    conversion = importlib.reload(conversion)
+
+import bpy
+
+addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
+
+
+def register() -> None:
+    bpy.types.WindowManager.poselib_flipped = bpy.props.BoolProperty(
+        name="Flip Pose",
+        default=False,
+    )
+    bpy.types.WindowManager.poselib_previous_action = bpy.props.PointerProperty(type=bpy.types.Action)
+
+    operators.register()
+    macros.register()
+    keymaps.register()
+    gui.register()
+
+
+def unregister() -> None:
+    gui.unregister()
+    keymaps.unregister()
+    macros.unregister()
+    operators.unregister()
+
+    try:
+        del bpy.types.WindowManager.poselib_flipped
+    except AttributeError:
+        pass
+    try:
+        del bpy.types.WindowManager.poselib_previous_action
+    except AttributeError:
+        pass
diff --git a/pose_library/asset_browser.py b/pose_library/asset_browser.py
new file mode 100644
index 0000000000000000000000000000000000000000..3983e610d4e9ac643d8d73ffd02234aab31a7f57
--- /dev/null
+++ b/pose_library/asset_browser.py
@@ -0,0 +1,79 @@
+"""Functions for finding and working with Asset Browsers."""
+
+from typing import Iterable, Optional, Tuple
+
+import bpy
+from bpy_extras import asset_utils
+
+
+if "functions" not in locals():
+    from . import functions
+else:
+    import importlib
+
+    functions = importlib.reload(functions)
+
+
+def area_for_category(screen: bpy.types.Screen, category: str) -> Optional[bpy.types.Area]:
+    """Return the asset browser area that is most suitable for managing the category.
+
+    :param screen: context.window.screen
+    :param category: asset category, see asset_category_items in rna_space.c
+
+    :return: the area, or None if no Asset Browser area exists.
+    """
+
+    def area_sorting_key(area: bpy.types.Area) -> Tuple[bool, int]:
+        """Return tuple (is correct category, area size in pixels)"""
+        space_data = area.spaces[0]
+        asset_cat: str = space_data.params.asset_category
+        return (asset_cat == category, area.width * area.height)
+
+    areas = list(suitable_areas(screen))
+    if not areas:
+        return None
+
+    return max(areas, key=area_sorting_key)
+
+
+def suitable_areas(screen: bpy.types.Screen) -> Iterable[bpy.types.Area]:
+    """Generator, yield Asset Browser areas."""
+
+    for area in screen.areas:
+        space_data = area.spaces[0]
+        if not asset_utils.SpaceAssetInfo.is_asset_browser(space_data):
+            continue
+        yield area
+
+
+def area_from_context(context: bpy.types.Context, category: str) -> Optional[bpy.types.Area]:
+    """Return an Asset Browser suitable for the given category.
+
+    Prefers the current Asset Browser if available, otherwise the biggest.
+    """
+
+    space_data = context.space_data
+    if not asset_utils.SpaceAssetInfo.is_asset_browser(space_data):
+        return area_for_category(context.screen, category)
+
+    if space_data.params.asset_category != category:
+        return area_for_category(context.screen, category)
+
+    return context.area
+
+
+def activate_asset(
+    asset: bpy.types.Action, asset_browser: bpy.types.Area, *, deferred: bool
+) -> None:
+    """Select & focus the asset in the browser."""
+
+    space_data = asset_browser.spaces[0]
+    assert asset_utils.SpaceAssetInfo.is_asset_browser(space_data)
+    space_data.activate_asset_by_id(asset, deferred=deferred)
+
+
+def tag_redraw(screen: bpy.types.Screen) -> None:
+    """Tag all asset browsers for redrawing."""
+
+    for area in suitable_areas(screen):
+        area.tag_redraw()
diff --git a/pose_library/conversion.py b/pose_library/conversion.py
new file mode 100644
index 0000000000000000000000000000000000000000..43a5d3a403f68f0d981239fa7cbafe1d83e325f7
--- /dev/null
+++ b/pose_library/conversion.py
@@ -0,0 +1,78 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - Conversion of old pose libraries.
+"""
+
+from typing import Optional
+from collections.abc import Collection
+
+if "pose_creation" not in locals():
+    from . import pose_creation
+else:
+    import importlib
+
+    pose_creation = importlib.reload(pose_creation)
+
+import bpy
+from bpy.types import (
+    Action,
+    TimelineMarker,
+)
+
+
+def convert_old_poselib(old_poselib: Action) -> Collection[Action]:
+    """Convert an old-style pose library to a set of pose Actions.
+
+    Old pose libraries were one Action with multiple pose markers. Each pose
+    marker will be converted to an Action by itself and marked as asset.
+    """
+
+    pose_assets = [
+        action
+        for marker in old_poselib.pose_markers
+        if (action := convert_old_pose(old_poselib, marker))
+    ]
+
+    # Mark all Actions as assets in one go. Ideally this would be done on an
+    # appropriate frame in the scene (to set up things like the background
+    # colour), but the old-style poselib doesn't contain such information. All
+    # we can do is just render on the current frame.
+    bpy.ops.asset.mark({'selected_ids': pose_assets})
+
+    return pose_assets
+
+
+def convert_old_pose(old_poselib: Action, marker: TimelineMarker) -> Optional[Action]:
+    """Convert an old-style pose library pose to a pose action."""
+
+    frame: int = marker.frame
+    action: Optional[Action] = None
+
+    for fcurve in old_poselib.fcurves:
+        key = pose_creation.find_keyframe(fcurve, frame)
+        if not key:
+            continue
+
+        if action is None:
+            action = bpy.data.actions.new(marker.name)
+
+        pose_creation.create_single_key_fcurve(action, fcurve, key)
+
+    return action
diff --git a/pose_library/functions.py b/pose_library/functions.py
new file mode 100644
index 0000000000000000000000000000000000000000..bb32e6691382c263162985be628c54fd3eb523d0
--- /dev/null
+++ b/pose_library/functions.py
@@ -0,0 +1,94 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - functions.
+"""
+
+from pathlib import Path
+from typing import Any, List, Set, cast, Iterable
+
+Datablock = Any
+
+import bpy
+from bpy.types import (
+    Context,
+)
+
+
+def asset_mark(context: Context, datablock: Any) -> Set[str]:
+    asset_mark_ctx = {
+        **context.copy(),
+        "id": datablock,
+    }
+    return cast(Set[str], bpy.ops.asset.mark(asset_mark_ctx))
+
+
+def asset_clear(context: Context, datablock: Any) -> Set[str]:
+    asset_clear_ctx = {
+        **context.copy(),
+        "id": datablock,
+    }
+    result = bpy.ops.asset.clear(asset_clear_ctx)
+    assert isinstance(result, set)
+    if "FINISHED" in result:
+        datablock.use_fake_user = False
+    return result
+
+
+def load_assets_from(filepath: Path) -> List[Datablock]:
+    if not has_assets(filepath):
+        # Avoid loading any datablocks when there are none marked as asset.
+        return []
+
+    # Append everything from the file.
+    with bpy.data.libraries.load(str(filepath)) as (
+        data_from,
+        data_to,
+    ):
+        for attr in dir(data_to):
+            setattr(data_to, attr, getattr(data_from, attr))
+
+    # Iterate over the appended datablocks to find assets.
+    def loaded_datablocks() -> Iterable[Datablock]:
+        for attr in dir(data_to):
+            datablocks = getattr(data_to, attr)
+            for datablock in datablocks:
+                yield datablock
+
+    loaded_assets = []
+    for datablock in loaded_datablocks():
+        if not getattr(datablock, "asset_data", None):
+            continue
+
+        # Fake User is lost when appending from another file.
+        datablock.use_fake_user = True
+        loaded_assets.append(datablock)
+    return loaded_assets
+
+
+def has_assets(filepath: Path) -> bool:
+    with bpy.data.libraries.load(str(filepath), assets_only=True) as (
+        data_from,
+        _,
+    ):
+        for attr in dir(data_from):
+            data_names = getattr(data_from, attr)
+            if data_names:
+                return True
+    return False
diff --git a/pose_library/gui.py b/pose_library/gui.py
new file mode 100644
index 0000000000000000000000000000000000000000..268c71cbc384f66563826e72e229b8bec55f5bd7
--- /dev/null
+++ b/pose_library/gui.py
@@ -0,0 +1,221 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - GUI definition.
+"""
+
+import bpy
+from bpy.types import (
+    AssetHandle,
+    Context,
+    Panel,
+    UIList,
+    WindowManager,
+    WorkSpace,
+)
+
+from bpy_extras import asset_utils
+
+
+class VIEW3D_PT_pose_library(Panel):
+    bl_space_type = "VIEW_3D"
+    bl_region_type = "UI"
+    bl_category = "Animation"
+    bl_label = "Pose Library"
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return context.preferences.experimental.use_asset_browser
+
+    def draw(self, context: Context) -> None:
+        layout = self.layout
+
+        row = layout.row(align=True)
+        row.operator("poselib.create_pose_asset").activate_new_action = False
+        if bpy.types.POSELIB_OT_restore_previous_action.poll(context):
+            row.operator("poselib.restore_previous_action", text="", icon='LOOP_BACK')
+        row.operator("poselib.copy_as_asset", icon="COPYDOWN", text="")
+
+        wm = context.window_manager
+        layout.prop(wm, "poselib_flipped")
+
+        if hasattr(layout, "template_asset_view"):
+            workspace = context.workspace
+            activate_op_props, drag_op_props = layout.template_asset_view(
+                "pose_assets",
+                workspace,
+                "active_asset_library",
+                wm,
+                "pose_assets",
+                workspace,
+                "active_pose_asset_index",
+                filter_id_types={"filter_action"},
+                activate_operator="poselib.apply_pose_asset",
+                drag_operator="poselib.blend_pose_asset",
+            )
+            drag_op_props.release_confirm = True
+            drag_op_props.flipped = wm.poselib_flipped
+            activate_op_props.flipped = wm.poselib_flipped
+
+
+def pose_library_list_item_context_menu(self: UIList, context: Context) -> None:
+    def is_pose_asset_view() -> bool:
+        # Important: Must check context first, or the menu is added for every kind of list.
+        list = getattr(context, "ui_list", None)
+        if not list or list.bl_idname != "UI_UL_asset_view" or list.list_id != "pose_assets":
+            return False
+        if not context.asset_handle:
+            return False
+        return True
+
+    def is_pose_library_asset_browser() -> bool:
+        asset_library = getattr(context, "asset_library", None)
+        if not asset_library:
+            return False
+        asset = getattr(context, "asset_file_handle", None)
+        if not asset:
+            return False
+        return bool(asset.id_type == 'ACTION')
+
+    if not is_pose_asset_view() and not is_pose_library_asset_browser():
+        return
+
+    layout = self.layout
+    wm = context.window_manager
+
+    layout.separator()
+
+    layout.operator("poselib.apply_pose_asset", text="Apply Pose")
+
+    old_op_ctx = layout.operator_context
+    layout.operator_context = 'INVOKE_DEFAULT'
+    props = layout.operator("poselib.blend_pose_asset", text="Blend Pose")
+    props.flipped = wm.poselib_flipped
+    layout.operator_context = old_op_ctx
+
+    props = layout.operator("poselib.pose_asset_select_bones", text="Select Pose Bones")
+    props.select = True
+    props = layout.operator("poselib.pose_asset_select_bones", text="Deselect Pose Bones")
+    props.select = False
+
+    layout.separator()
+    layout.operator("asset.open_containing_blend_file")
+
+
+class ASSETBROWSER_PT_pose_library_usage(asset_utils.AssetBrowserSpecificCategoryPanel, Panel):
+    bl_region_type = "TOOLS"
+    bl_label = "Pose Library"
+    asset_categories = {'ANIMATIONS'}
+
+    def draw(self, context: Context) -> None:
+        layout = self.layout
+        wm = context.window_manager
+
+        col = layout.column(align=True)
+        col.label(text="Use Pose Asset")
+        col.prop(wm, "poselib_flipped")
+        props = col.operator("poselib.apply_pose_asset")
+        props.flipped = wm.poselib_flipped
+        props = col.operator("poselib.blend_pose_asset")
+        props.flipped = wm.poselib_flipped
+
+        row = col.row(align=True)
+        props = row.operator("poselib.pose_asset_select_bones", text="Select", icon="BONE_DATA")
+        props.flipped = wm.poselib_flipped
+        props.select = True
+        props = row.operator("poselib.pose_asset_select_bones", text="Deselect")
+        props.flipped = wm.poselib_flipped
+        props.select = False
+
+
+class ASSETBROWSER_PT_pose_library_editing(asset_utils.AssetBrowserSpecificCategoryPanel, Panel):
+    bl_region_type = "TOOL_PROPS"
+    bl_label = "Pose Library"
+    asset_categories = {'ANIMATIONS'}
+
+    def draw(self, context: Context) -> None:
+        layout = self.layout
+
+        col = layout.column(align=True)
+        col.enabled = bpy.types.ASSET_OT_assign_action.poll(context)
+        col.label(text="Activate & Edit")
+        col.operator("asset.assign_action")
+
+        # Creation
+        col = layout.column(align=True)
+        col.enabled = bpy.types.POSELIB_OT_paste_asset.poll(context)
+        col.label(text="Create Pose Asset")
+        col.operator("poselib.paste_asset", icon="PASTEDOWN")
+
+
+class DOPESHEET_PT_asset_panel(Panel):
+    bl_space_type = "DOPESHEET_EDITOR"
+    bl_region_type = "UI"
+    bl_label = "Create Pose Asset"
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return context.preferences.experimental.use_asset_browser
+
+    def draw(self, context: Context) -> None:
+        layout = self.layout
+        col = layout.column(align=True)
+        row = col.row(align=True)
+        row.operator("poselib.create_pose_asset").activate_new_action = True
+        if bpy.types.POSELIB_OT_restore_previous_action.poll(context):
+            row.operator("poselib.restore_previous_action", text="", icon='LOOP_BACK')
+        col.operator("poselib.copy_as_asset", icon="COPYDOWN")
+
+        layout.operator("poselib.convert_old_poselib")
+
+
+classes = (
+    ASSETBROWSER_PT_pose_library_editing,
+    ASSETBROWSER_PT_pose_library_usage,
+    DOPESHEET_PT_asset_panel,
+    VIEW3D_PT_pose_library,
+)
+
+_register, _unregister = bpy.utils.register_classes_factory(classes)
+
+
+def register() -> None:
+    _register()
+
+    WorkSpace.active_pose_asset_index = bpy.props.IntProperty(
+        name="Active Pose Asset",
+        # TODO explain which list the index belongs to, or how it can be used to get the pose.
+        description="Per workspace index of the active pose asset"
+    )
+    # Register for window-manager. This is a global property that shouldn't be
+    # written to files.
+    WindowManager.pose_assets = bpy.props.CollectionProperty(type=AssetHandle)
+
+    bpy.types.UI_MT_list_item_context_menu.prepend(pose_library_list_item_context_menu)
+    bpy.types.FILEBROWSER_MT_context_menu.prepend(pose_library_list_item_context_menu)
+
+
+def unregister() -> None:
+    _unregister()
+
+    del WorkSpace.active_pose_asset_index
+    del WindowManager.pose_assets
+
+    bpy.types.UI_MT_list_item_context_menu.remove(pose_library_list_item_context_menu)
+    bpy.types.FILEBROWSER_MT_context_menu.remove(pose_library_list_item_context_menu)
diff --git a/pose_library/keymaps.py b/pose_library/keymaps.py
new file mode 100644
index 0000000000000000000000000000000000000000..87ccf57236eaee94c0b0640dccdfc67255b280a1
--- /dev/null
+++ b/pose_library/keymaps.py
@@ -0,0 +1,43 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+from typing import List, Tuple
+
+import bpy
+
+addon_keymaps: List[Tuple[bpy.types.KeyMap, bpy.types.KeyMapItem]] = []
+
+
+def register() -> None:
+    wm = bpy.context.window_manager
+    if wm.keyconfigs.addon is None:
+        # This happens when Blender is running in the background.
+        return
+
+    km = wm.keyconfigs.addon.keymaps.new(name="File Browser Main", space_type="FILE_BROWSER")
+
+    # DblClick to apply pose.
+    kmi = km.keymap_items.new("poselib.apply_pose_asset_for_keymap", "LEFTMOUSE", "DOUBLE_CLICK")
+    addon_keymaps.append((km, kmi))
+
+
+def unregister() -> None:
+    # Clear shortcuts from the keymap.
+    for km, kmi in addon_keymaps:
+        km.keymap_items.remove(kmi)
+    addon_keymaps.clear()
diff --git a/pose_library/macros.py b/pose_library/macros.py
new file mode 100644
index 0000000000000000000000000000000000000000..35f333089f6a43cbb04c23dd9614996683536ebf
--- /dev/null
+++ b/pose_library/macros.py
@@ -0,0 +1,61 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - macros.
+"""
+
+import bpy
+
+
+class POSELIB_OT_select_asset_and_select_bones(bpy.types.Macro):
+    bl_idname = "poselib.select_asset_and_select_bones"
+    bl_label = "Select Pose & Select Bones"
+
+
+class POSELIB_OT_select_asset_and_deselect_bones(bpy.types.Macro):
+    bl_idname = "poselib.select_asset_and_deselect_bones"
+    bl_label = "Select Pose & Deselect Bones"
+
+
+classes = (
+    POSELIB_OT_select_asset_and_select_bones,
+    POSELIB_OT_select_asset_and_deselect_bones,
+)
+
+_register, _unregister = bpy.utils.register_classes_factory(classes)
+
+
+def register() -> None:
+    _register()
+
+    step = POSELIB_OT_select_asset_and_select_bones.define("FILE_OT_select")
+    step.properties.open = False
+    step.properties.deselect_all = True
+    step = POSELIB_OT_select_asset_and_select_bones.define("POSELIB_OT_pose_asset_select_bones")
+    step.properties.select = True
+
+    step = POSELIB_OT_select_asset_and_deselect_bones.define("FILE_OT_select")
+    step.properties.open = False
+    step.properties.deselect_all = True
+    step = POSELIB_OT_select_asset_and_deselect_bones.define("POSELIB_OT_pose_asset_select_bones")
+    step.properties.select = False
+
+
+def unregister() -> None:
+    _unregister()
diff --git a/pose_library/operators.py b/pose_library/operators.py
new file mode 100644
index 0000000000000000000000000000000000000000..f06241d78835df389acc456f45961cf76a0b646c
--- /dev/null
+++ b/pose_library/operators.py
@@ -0,0 +1,439 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - operators.
+"""
+
+from pathlib import Path
+from typing import Optional, Set
+
+_need_reload = "functions" in locals()
+from . import asset_browser, functions, pose_creation, pose_usage
+
+if _need_reload:
+    import importlib
+
+    asset_browser = importlib.reload(asset_browser)
+    functions = importlib.reload(functions)
+    pose_creation = importlib.reload(pose_creation)
+    pose_usage = importlib.reload(pose_usage)
+
+
+import bpy
+from bpy.props import BoolProperty, StringProperty
+from bpy.types import (
+    Action,
+    Context,
+    Event,
+    FileSelectEntry,
+    Object,
+    Operator,
+)
+
+
+class PoseAssetCreator:
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return bool(
+            # There must be an object.
+            context.object
+            # It must be in pose mode with selected bones.
+            and context.object.mode == "POSE"
+            and context.object.pose
+            and context.selected_pose_bones_from_active_object
+        )
+
+
+class LocalPoseAssetUser:
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return bool(
+            isinstance(getattr(context, "id", None), Action)
+            and context.object
+            and context.object.mode == "POSE"  # This condition may not be desired.
+        )
+
+
+class POSELIB_OT_create_pose_asset(PoseAssetCreator, Operator):
+    bl_idname = "poselib.create_pose_asset"
+    bl_label = "Create Pose Asset"
+    bl_description = (
+        "Create a new Action that contains the pose of the selected bones, and mark it as Asset"
+    )
+    bl_options = {"REGISTER", "UNDO"}
+
+    pose_name: StringProperty(name="Pose Name")  # type: ignore
+    activate_new_action: BoolProperty(name="Activate New Action", default=True)  # type: ignore
+
+    def execute(self, context: Context) -> Set[str]:
+        pose_name = self.pose_name or context.object.name
+        asset = pose_creation.create_pose_asset_from_context(context, pose_name)
+        if not asset:
+            self.report({"WARNING"}, "No keyframes were found for this pose")
+            return {"CANCELLED"}
+
+        if self.activate_new_action:
+            self._set_active_action(context, asset)
+        self._activate_asset_in_browser(context, asset)
+        return {'FINISHED'}
+
+    def _set_active_action(self, context: Context, asset: Action) -> None:
+        self._prevent_action_loss(context.object)
+
+        anim_data = context.object.animation_data_create()
+        context.window_manager.poselib_previous_action = anim_data.action
+        anim_data.action = asset
+
+    def _activate_asset_in_browser(self, context: Context, asset: Action) -> None:
+        """Activate the new asset in the appropriate Asset Browser.
+
+        This makes it possible to immediately check & edit the created pose asset.
+        """
+
+        asset_browse_area: Optional[bpy.types.Area] = asset_browser.area_for_category(
+            context.screen, "ANIMATION"
+        )
+        if not asset_browse_area:
+            return
+
+        # After creating an asset, the window manager has to process the
+        # notifiers before editors should be manipulated.
+        pose_creation.assign_tags_from_asset_browser(asset, asset_browse_area)
+
+        # Pass deferred=True, because we just created a new asset that isn't
+        # known to the Asset Browser space yet. That requires the processing of
+        # notifiers, which will only happen after this code has finished
+        # running.
+        asset_browser.activate_asset(asset, asset_browse_area, deferred=True)
+
+    def _prevent_action_loss(self, object: Object) -> None:
+        """Mark the action with Fake User if necessary.
+
+        This is to prevent action loss when we reduce its reference counter by one.
+        """
+
+        if not object.animation_data:
+            return
+
+        action = object.animation_data.action
+        if not action:
+            return
+
+        if action.use_fake_user or action.users > 1:
+            # Removing one user won't GC it.
+            return
+
+        action.use_fake_user = True
+        self.report({'WARNING'}, "Action %s marked Fake User to prevent loss" % action.name)
+
+
+class POSELIB_OT_restore_previous_action(Operator):
+    bl_idname = "poselib.restore_previous_action"
+    bl_label = "Restore Previous Action"
+    bl_description = "Switch back to the previous Action, after creating a pose asset"
+    bl_options = {"REGISTER", "UNDO"}
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return bool(
+            context.window_manager.poselib_previous_action
+            and context.object
+            and context.object.animation_data
+            and context.object.animation_data.action
+            and context.object.animation_data.action.asset_data is not None
+        )
+
+    def execute(self, context: Context) -> Set[str]:
+        # This is the Action that was just created with "Create Pose Asset".
+        # It has to be re-applied after switching to the previous action,
+        # to ensure the character keeps the same pose.
+        self.pose_action = context.object.animation_data.action
+
+        prev_action = context.window_manager.poselib_previous_action
+        context.object.animation_data.action = prev_action
+        context.window_manager.poselib_previous_action = None
+
+        # Wait a bit for the action assignment to be handled, before applying the pose.
+        wm = context.window_manager
+        self._timer = wm.event_timer_add(0.001, window=context.window)
+        wm.modal_handler_add(self)
+
+        return {'RUNNING_MODAL'}
+
+    def modal(self, context, event):
+        if event.type != 'TIMER':
+            return {'RUNNING_MODAL'}
+
+        wm = context.window_manager
+        wm.event_timer_remove(self._timer)
+
+        context.object.pose.apply_pose_from_action(self.pose_action)
+        return {'FINISHED'}
+
+
+class ASSET_OT_assign_action(LocalPoseAssetUser, Operator):
+    bl_idname = "asset.assign_action"
+    bl_label = "Assign Action"
+    bl_description = "Set this pose Action as active Action on the active Object"
+    bl_options = {"REGISTER", "UNDO"}
+
+    def execute(self, context: Context) -> Set[str]:
+        context.object.animation_data_create().action = context.id
+        return {"FINISHED"}
+
+
+class POSELIB_OT_copy_as_asset(PoseAssetCreator, Operator):
+    bl_idname = "poselib.copy_as_asset"
+    bl_label = "Copy Pose As Asset"
+    bl_description = "Create a new pose asset on the clipboard, to be pasted into an Asset Browser"
+    bl_options = {"REGISTER"}
+
+    CLIPBOARD_ASSET_MARKER = "ASSET-BLEND="
+
+    def execute(self, context: Context) -> Set[str]:
+        asset = pose_creation.create_pose_asset_from_context(
+            context, new_asset_name=context.object.name
+        )
+        if asset is None:
+            self.report({"WARNING"}, "No animation data found to create asset from")
+            return {"CANCELLED"}
+
+        filepath = self.save_datablock(asset)
+
+        functions.asset_clear(context, asset)
+        if asset.users > 0:
+            self.report({"ERROR"}, "Whaaaat who is using our brand new asset?")
+            return {"FINISHED"}
+
+        bpy.data.actions.remove(asset)
+
+        context.window_manager.clipboard = "%s%s" % (
+            self.CLIPBOARD_ASSET_MARKER,
+            filepath,
+        )
+
+        asset_browser.tag_redraw(context.screen)
+        self.report({"INFO"}, "Pose Asset copied, use Paste As New Asset in any Asset Browser to paste")
+        return {"FINISHED"}
+
+    def save_datablock(self, action: Action) -> Path:
+        tempdir = Path(bpy.app.tempdir)
+        filepath = tempdir / "copied_asset.blend"
+        bpy.data.libraries.write(
+            str(filepath),
+            datablocks={action},
+            path_remap="NONE",
+            fake_user=True,
+            compress=True,  # Single-datablock blend file, likely little need to diff.
+        )
+        return filepath
+
+
+class POSELIB_OT_paste_asset(Operator):
+    bl_idname = "poselib.paste_asset"
+    bl_label = "Paste As New Asset"
+    bl_description = "Paste the Asset that was previously copied using Copy As Asset"
+    bl_options = {"REGISTER", "UNDO"}
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        clipboard: str = context.window_manager.clipboard
+        if not clipboard:
+            return False
+        marker = POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER
+        return clipboard.startswith(marker)
+
+    def execute(self, context: Context) -> Set[str]:
+        clipboard = context.window_manager.clipboard
+        marker_len = len(POSELIB_OT_copy_as_asset.CLIPBOARD_ASSET_MARKER)
+        filepath = Path(clipboard[marker_len:])
+
+        assets = functions.load_assets_from(filepath)
+        if not assets:
+            self.report({"ERROR"}, "Did not find any assets on clipboard")
+            return {"CANCELLED"}
+
+        self.report({"INFO"}, "Pasted %d assets" % len(assets))
+
+        bpy.ops.file.refresh()
+        asset_browser_area = asset_browser.area_from_context(context, 'ANIMATIONS')
+        if asset_browser_area:
+            asset_browser.activate_asset(assets[0], asset_browser_area, deferred=True)
+
+        return {"FINISHED"}
+
+
+class PoseAssetUser:
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        if not (
+            context.object
+            and context.object.mode == "POSE"  # This condition may not be desired.
+            and context.asset_library
+            and context.asset_file_handle
+        ):
+            return False
+        return context.asset_file_handle.id_type == 'ACTION'
+
+    def execute(self, context: Context) -> Set[str]:
+        asset: FileSelectEntry = context.asset_file_handle
+        if asset.local_id:
+            return self.use_pose(context, asset.local_id)
+        return self._load_and_use_pose(context)
+
+    def use_pose(self, context: Context, asset: bpy.types.ID) -> Set[str]:
+        # Implement in subclass.
+        pass
+
+    def _load_and_use_pose(self, context: Context) -> Set[str]:
+        asset_library = context.asset_library
+        asset = context.asset_file_handle
+        asset_lib_path = bpy.types.AssetHandle.get_full_library_path(asset, asset_library)
+
+        if not asset_lib_path:
+            self.report(  # type: ignore
+                {"ERROR"},
+                # TODO: Add some way to get the library name from the library reference (just asset_library.name?).
+                f"Selected asset {asset.name} could not be located inside the asset library",
+            )
+            return {"CANCELLED"}
+        if asset.id_type != 'ACTION':
+            self.report(  # type: ignore
+                {"ERROR"},
+                f"Selected asset {asset.name} is not an Action",
+            )
+            return {"CANCELLED"}
+
+        with bpy.types.BlendData.temp_data() as temp_data:
+            with temp_data.libraries.load(asset_lib_path) as (data_from, data_to):
+                data_to.actions = [asset.name]
+
+            action: Action = data_to.actions[0]
+            return self.use_pose(context, action)
+
+
+class POSELIB_OT_pose_asset_select_bones(PoseAssetUser, Operator):
+    bl_idname = "poselib.pose_asset_select_bones"
+    bl_label = "Select Bones"
+    bl_description = "Select those bones that are used in this pose"
+    bl_options = {"REGISTER", "UNDO"}
+
+    select: BoolProperty(name="Select", default=True)  # type: ignore
+    flipped: BoolProperty(name="Flipped", default=False)  # type: ignore
+
+    def use_pose(self, context: Context, pose_asset: Action) -> Set[str]:
+        arm_object: Object = context.object
+        pose_usage.select_bones(arm_object, pose_asset, select=self.select, flipped=self.flipped)
+        verb = "Selected" if self.select else "Deselected"
+        self.report({"INFO"}, f"{verb} bones from {pose_asset.name}")
+        return {"FINISHED"}
+
+    @classmethod
+    def description(
+        cls, _context: Context, properties: 'POSELIB_OT_pose_asset_select_bones'
+    ) -> str:
+        if properties.select:
+            return cls.bl_description
+        return cls.bl_description.replace("Select", "Deselect")
+
+
+class POSELIB_OT_blend_pose_asset_for_keymap(Operator):
+    bl_idname = "poselib.blend_pose_asset_for_keymap"
+    bl_options = {"REGISTER", "UNDO"}
+
+    _rna = bpy.ops.poselib.blend_pose_asset.get_rna_type()
+    bl_label = _rna.name
+    bl_description = _rna.description
+    del _rna
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return bpy.ops.poselib.blend_pose_asset.poll(context.copy())
+
+    def execute(self, context: Context) -> Set[str]:
+        flipped = context.window_manager.poselib_flipped
+        return bpy.ops.poselib.blend_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
+
+    def invoke(self, context: Context, event: Event) -> Set[str]:
+        flipped = context.window_manager.poselib_flipped
+        return bpy.ops.poselib.blend_pose_asset(context.copy(), 'INVOKE_DEFAULT', flipped=flipped)
+
+
+class POSELIB_OT_apply_pose_asset_for_keymap(Operator):
+    bl_idname = "poselib.apply_pose_asset_for_keymap"
+    bl_options = {"REGISTER", "UNDO"}
+
+    _rna = bpy.ops.poselib.apply_pose_asset.get_rna_type()
+    bl_label = _rna.name
+    bl_description = _rna.description
+    del _rna
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        return bpy.ops.poselib.apply_pose_asset.poll(context.copy())
+
+    def execute(self, context: Context) -> Set[str]:
+        flipped = context.window_manager.poselib_flipped
+        return bpy.ops.poselib.apply_pose_asset(context.copy(), 'EXEC_DEFAULT', flipped=flipped)
+
+
+class POSELIB_OT_convert_old_poselib(Operator):
+    bl_idname = "poselib.convert_old_poselib"
+    bl_label = "Convert old-style pose library"
+    bl_description = "Create a pose asset for each pose marker in the current action"
+    bl_options = {"REGISTER", "UNDO"}
+
+    @classmethod
+    def poll(cls, context: Context) -> bool:
+        action = context.object and context.object.animation_data and context.object.animation_data.action
+        if not action:
+            cls.poll_message_set("Active object has no Action")
+            return False
+        if not action.pose_markers:
+            cls.poll_message_set("Action %r is not a old-style pose library" % action.name)
+            return False
+        return True
+
+    def execute(self, context: Context) -> Set[str]:
+        from . import conversion
+
+        old_poselib = context.object.animation_data.action
+        new_actions = conversion.convert_old_poselib(old_poselib)
+
+        if not new_actions:
+            self.report({'ERROR'}, "Unable to convert to pose assets")
+            return {'CANCELLED'}
+
+        self.report({'INFO'}, "Converted %d poses to pose assets" % len(new_actions))
+        return {'FINISHED'}
+
+
+classes = (
+    ASSET_OT_assign_action,
+    POSELIB_OT_apply_pose_asset_for_keymap,
+    POSELIB_OT_blend_pose_asset_for_keymap,
+    POSELIB_OT_convert_old_poselib,
+    POSELIB_OT_copy_as_asset,
+    POSELIB_OT_create_pose_asset,
+    POSELIB_OT_paste_asset,
+    POSELIB_OT_pose_asset_select_bones,
+    POSELIB_OT_restore_previous_action,
+)
+
+register, unregister = bpy.utils.register_classes_factory(classes)
diff --git a/pose_library/pose_creation.py b/pose_library/pose_creation.py
new file mode 100644
index 0000000000000000000000000000000000000000..79efcae413083615533d559acd53dc34c2f42397
--- /dev/null
+++ b/pose_library/pose_creation.py
@@ -0,0 +1,437 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - creation functions.
+"""
+
+import dataclasses
+import functools
+import re
+from typing import Optional, FrozenSet, Set, Union, Iterable, cast
+
+if "functions" not in locals():
+    from . import functions
+else:
+    import importlib
+
+    functions = importlib.reload(functions)
+
+import bpy
+from bpy.types import (
+    Action,
+    Bone,
+    Context,
+    FCurve,
+    Keyframe,
+)
+
+FCurveValue = Union[float, int]
+
+pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
+"""RegExp for matching FCurve data paths."""
+
+
+@dataclasses.dataclass(unsafe_hash=True, frozen=True)
+class PoseCreationParams:
+    armature_ob: bpy.types.Object
+    src_action: Optional[Action]
+    src_frame_nr: float
+    bone_names: FrozenSet[str]
+    new_asset_name: str
+
+
+class UnresolvablePathError(ValueError):
+    """Raised when a data_path cannot be resolved to a current value."""
+
+
+@dataclasses.dataclass(unsafe_hash=True)
+class PoseActionCreator:
+    """Create an Action that's suitable for marking as Asset.
+
+    Does not mark as asset yet, nor does it add asset metadata.
+    """
+
+    params: PoseCreationParams
+
+    # These were taken from Blender's Action baking code in `anim_utils.py`.
+    # Items are (name, array_length) tuples.
+    _bbone_props = [
+        ("bbone_curveinx", None),
+        ("bbone_curveoutx", None),
+        ("bbone_curveinz", None),
+        ("bbone_curveoutz", None),
+        ("bbone_rollin", None),
+        ("bbone_rollout", None),
+        ("bbone_scalein", 3),
+        ("bbone_scaleout", 3),
+        ("bbone_easein", None),
+        ("bbone_easeout", None),
+    ]
+
+    def create(self) -> Optional[Action]:
+        """Create a single-frame Action containing only the given bones, or None if no anim data was found."""
+
+        try:
+            dst_action = self._create_new_action()
+            self._store_pose(dst_action)
+        finally:
+            # Prevent next instantiations of this class from reusing pointers to
+            # bones. They may not be valid by then any more.
+            self._find_bone.cache_clear()
+
+        if len(dst_action.fcurves) == 0:
+            bpy.data.actions.remove(dst_action)
+            return None
+
+        return dst_action
+
+    def _create_new_action(self) -> Action:
+        dst_action = bpy.data.actions.new(self.params.new_asset_name)
+        if self.params.src_action:
+            dst_action.id_root = self.params.src_action.id_root
+        dst_action.user_clear()  # actions.new() sets users=1, but marking as asset also increments user count.
+        return dst_action
+
+    def _store_pose(self, dst_action: Action) -> None:
+        """Store the current pose into the given action."""
+        self._store_bone_pose_parameters(dst_action)
+        self._store_animated_parameters(dst_action)
+        self._store_parameters_from_callback(dst_action)
+
+    def _store_bone_pose_parameters(self, dst_action: Action) -> None:
+        """Store loc/rot/scale/bbone values in the Action."""
+
+        for bone_name in sorted(self.params.bone_names):
+            self._store_location(dst_action, bone_name)
+            self._store_rotation(dst_action, bone_name)
+            self._store_scale(dst_action, bone_name)
+            self._store_bbone(dst_action, bone_name)
+
+    def _store_animated_parameters(self, dst_action: Action) -> None:
+        """Store the current value of any animated bone properties."""
+        if self.params.src_action is None:
+            return
+
+        armature_ob = self.params.armature_ob
+        for fcurve in self.params.src_action.fcurves:
+            match = pose_bone_re.match(fcurve.data_path)
+            if not match:
+                # Not animating a bone property.
+                continue
+
+            bone_name = match.group(1)
+            if bone_name not in self.params.bone_names:
+                # Bone is not our export set.
+                continue
+
+            if dst_action.fcurves.find(fcurve.data_path, index=fcurve.array_index):
+                # This property is already handled by a previous _store_xxx() call.
+                continue
+
+            # Only include in the pose if there is a key on this frame.
+            if not self._has_key_on_frame(fcurve):
+                continue
+
+            try:
+                value = self._current_value(armature_ob, fcurve.data_path, fcurve.array_index)
+            except UnresolvablePathError:
+                # A once-animated property no longer exists.
+                continue
+
+            dst_fcurve = dst_action.fcurves.new(
+                fcurve.data_path, index=fcurve.array_index, action_group=bone_name
+            )
+            dst_fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value)
+            dst_fcurve.update()
+
+    def _store_parameters_from_callback(self, dst_action: Action) -> None:
+        """Store extra parameters in the pose based on arbitrary callbacks.
+
+        Not implemented yet, needs a proper design & some user stories.
+        """
+        pass
+
+    def _store_location(self, dst_action: Action, bone_name: str) -> None:
+        """Store bone location."""
+        self._store_bone_array(dst_action, bone_name, "location", 3)
+
+    def _store_rotation(self, dst_action: Action, bone_name: str) -> None:
+        """Store bone rotation given current rotation mode."""
+        bone = self._find_bone(bone_name)
+        if bone.rotation_mode == "QUATERNION":
+            self._store_bone_array(dst_action, bone_name, "rotation_quaternion", 4)
+        elif bone.rotation_mode == "AXIS_ANGLE":
+            self._store_bone_array(dst_action, bone_name, "rotation_axis_angle", 4)
+        else:
+            self._store_bone_array(dst_action, bone_name, "rotation_euler", 3)
+
+    def _store_scale(self, dst_action: Action, bone_name: str) -> None:
+        """Store bone scale."""
+        self._store_bone_array(dst_action, bone_name, "scale", 3)
+
+    def _store_bbone(self, dst_action: Action, bone_name: str) -> None:
+        """Store bendy-bone parameters."""
+        for prop_name, array_length in self._bbone_props:
+            if array_length:
+                self._store_bone_array(dst_action, bone_name, prop_name, array_length)
+            else:
+                self._store_bone_property(dst_action, bone_name, prop_name)
+
+    def _store_bone_array(
+        self, dst_action: Action, bone_name: str, property_name: str, array_length: int
+    ) -> None:
+        """Store all elements of an array property."""
+        for array_index in range(array_length):
+            self._store_bone_property(dst_action, bone_name, property_name, array_index)
+
+    def _store_bone_property(
+        self,
+        dst_action: Action,
+        bone_name: str,
+        property_path: str,
+        array_index: int = -1,
+    ) -> None:
+        """Store the current value of a single bone property."""
+
+        bone = self._find_bone(bone_name)
+        value = self._current_value(bone, property_path, array_index)
+
+        # Get the full 'pose.bones["bone_name"].blablabla' path suitable for FCurves.
+        rna_path = bone.path_from_id(property_path)
+
+        fcurve: Optional[FCurve] = dst_action.fcurves.find(rna_path, index=array_index)
+        if fcurve is None:
+            fcurve = dst_action.fcurves.new(rna_path, index=array_index, action_group=bone_name)
+
+        fcurve.keyframe_points.insert(self.params.src_frame_nr, value=value)
+        fcurve.update()
+
+    @classmethod
+    def _current_value(
+        cls, datablock: bpy.types.ID, data_path: str, array_index: int
+    ) -> FCurveValue:
+        """Resolve an RNA path + array index to an actual value."""
+        value_or_array = cls._path_resolve(datablock, data_path)
+
+        # Both indices -1 and 0 are used for non-array properties.
+        # -1 cannot be used in arrays, whereas 0 can be used in both arrays and non-arrays.
+
+        if array_index == -1:
+            return cast(FCurveValue, value_or_array)
+
+        if array_index == 0:
+            value_or_array = cls._path_resolve(datablock, data_path)
+            try:
+                # MyPy doesn't understand this try/except is to determine the type.
+                value = value_or_array[array_index]  # type: ignore
+            except TypeError:
+                # Not an array after all.
+                return cast(FCurveValue, value_or_array)
+            return cast(FCurveValue, value)
+
+        # MyPy doesn't understand that array_index>0 implies this is indexable.
+        return cast(FCurveValue, value_or_array[array_index])  # type: ignore
+
+    @staticmethod
+    def _path_resolve(
+        datablock: bpy.types.ID, data_path: str
+    ) -> Union[FCurveValue, Iterable[FCurveValue]]:
+        """Wrapper for datablock.path_resolve(data_path).
+
+        Raise UnresolvablePathError when the path cannot be resolved.
+        This is easier to deal with upstream than the generic ValueError raised
+        by Blender.
+        """
+        try:
+            return datablock.path_resolve(data_path)  # type: ignore
+        except ValueError as ex:
+            raise UnresolvablePathError(str(ex)) from ex
+
+    @functools.lru_cache(maxsize=1024)
+    def _find_bone(self, bone_name: str) -> Bone:
+        """Find a bone by name.
+
+        Assumes the named bone exists, as the bones this class handles comes
+        from the user's selection, and you can't select a non-existent bone.
+        """
+
+        bone: Bone = self.params.armature_ob.pose.bones[bone_name]
+        return bone
+
+    def _has_key_on_frame(self, fcurve: FCurve) -> bool:
+        """Return True iff the FCurve has a key on the source frame."""
+
+        points = fcurve.keyframe_points
+        if not points:
+            return False
+
+        frame_to_find = self.params.src_frame_nr
+        margin = 0.001
+        high = len(points) - 1
+        low = 0
+        while low <= high:
+            mid = (high + low) // 2
+            diff = points[mid].co.x - frame_to_find
+            if abs(diff) < margin:
+                return True
+            if diff < 0:
+                # Frame to find is bigger than the current middle.
+                low = mid + 1
+            else:
+                # Frame to find is smaller than the current middle
+                high = mid - 1
+        return False
+
+
+def create_pose_asset(
+    context: Context,
+    params: PoseCreationParams,
+) -> Optional[Action]:
+    """Create a single-frame Action containing only the pose of the given bones.
+
+    DOES mark as asset, DOES NOT add asset metadata.
+    """
+
+    creator = PoseActionCreator(params)
+    pose_action = creator.create()
+    if pose_action is None:
+        return None
+
+    functions.asset_mark(context, pose_action)
+    return pose_action
+
+
+def create_pose_asset_from_context(context: Context, new_asset_name: str) -> Optional[Action]:
+    """Create Action asset from active object & selected bones."""
+
+    bones = context.selected_pose_bones_from_active_object
+    bone_names = {bone.name for bone in bones}
+
+    params = PoseCreationParams(
+        context.object,
+        getattr(context.object.animation_data, "action", None),
+        context.scene.frame_current,
+        frozenset(bone_names),
+        new_asset_name,
+    )
+
+    return create_pose_asset(context, params)
+
+
+def copy_fcurves(
+    dst_action: Action,
+    src_action: Action,
+    src_frame_nr: float,
+    bone_names: Set[str],
+) -> int:
+    """Copy FCurves, returning number of curves copied."""
+    num_fcurves_copied = 0
+    for fcurve in src_action.fcurves:
+        match = pose_bone_re.match(fcurve.data_path)
+        if not match:
+            continue
+
+        bone_name = match.group(1)
+        if bone_name not in bone_names:
+            continue
+
+        # Check if there is a keyframe on this frame.
+        keyframe = find_keyframe(fcurve, src_frame_nr)
+        if keyframe is None:
+            continue
+        create_single_key_fcurve(dst_action, fcurve, keyframe)
+        num_fcurves_copied += 1
+    return num_fcurves_copied
+
+
+def create_single_key_fcurve(
+    dst_action: Action, src_fcurve: FCurve, src_keyframe: Keyframe
+) -> FCurve:
+    """Create a copy of the source FCurve, but only for the given keyframe.
+
+    Returns a new FCurve with just one keyframe.
+    """
+
+    dst_fcurve = copy_fcurve_without_keys(dst_action, src_fcurve)
+    copy_keyframe(dst_fcurve, src_keyframe)
+    return dst_fcurve
+
+
+def copy_fcurve_without_keys(dst_action: Action, src_fcurve: FCurve) -> FCurve:
+    """Create a new FCurve and copy some properties."""
+
+    src_group_name = src_fcurve.group.name if src_fcurve.group else ""
+    dst_fcurve = dst_action.fcurves.new(
+        src_fcurve.data_path, index=src_fcurve.array_index, action_group=src_group_name
+    )
+    for propname in {"auto_smoothing", "color", "color_mode", "extrapolation"}:
+        setattr(dst_fcurve, propname, getattr(src_fcurve, propname))
+    return dst_fcurve
+
+
+def copy_keyframe(dst_fcurve: FCurve, src_keyframe: Keyframe) -> Keyframe:
+    """Copy a keyframe from one FCurve to the other."""
+
+    dst_keyframe = dst_fcurve.keyframe_points.insert(
+        src_keyframe.co.x, src_keyframe.co.y, options={'FAST'}, keyframe_type=src_keyframe.type
+    )
+
+    for propname in {
+        "amplitude",
+        "back",
+        "easing",
+        "handle_left",
+        "handle_left_type",
+        "handle_right",
+        "handle_right_type",
+        "interpolation",
+        "period",
+    }:
+        setattr(dst_keyframe, propname, getattr(src_keyframe, propname))
+    dst_fcurve.update()
+    return dst_keyframe
+
+
+def find_keyframe(fcurve: FCurve, frame: float) -> Optional[Keyframe]:
+    # Binary search adapted from https://pythonguides.com/python-binary-search/
+    keyframes = fcurve.keyframe_points
+    low = 0
+    high = len(keyframes) - 1
+    mid = 0
+
+    # Accept any keyframe that's within 'epsilon' of the requested frame.
+    # This should account for rounding errors and the likes.
+    epsilon = 1e-4
+    frame_lowerbound = frame - epsilon
+    frame_upperbound = frame + epsilon
+    while low <= high:
+        mid = (high + low) // 2
+        keyframe = keyframes[mid]
+        if keyframe.co.x < frame_lowerbound:
+            low = mid + 1
+        elif keyframe.co.x > frame_upperbound:
+            high = mid - 1
+        else:
+            return keyframe
+    return None
+
+
+def assign_tags_from_asset_browser(asset: Action, asset_browser: bpy.types.Area) -> None:
+    # TODO(Sybren): implement
+    return
diff --git a/pose_library/pose_usage.py b/pose_library/pose_usage.py
new file mode 100644
index 0000000000000000000000000000000000000000..dc496d9c98c4bb84db2348d529b619360c2cee8a
--- /dev/null
+++ b/pose_library/pose_usage.py
@@ -0,0 +1,185 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+#  This program is free software; you can redistribute it and/or
+#  modify it under the terms of the GNU General Public License
+#  as published by the Free Software Foundation; either version 2
+#  of the License, or (at your option) any later version.
+#
+#  This program is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with this program; if not, write to the Free Software Foundation,
+#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+"""
+Pose Library - usage functions.
+"""
+
+from typing import Set
+import re
+
+from bpy.types import (
+    Action,
+    Object,
+)
+
+
+def select_bones(arm_object: Object, action: Action, *, select: bool, flipped: bool) -> None:
+    pose_bone_re = re.compile(r'pose.bones\["([^"]+)"\]')
+    pose = arm_object.pose
+
+    seen_bone_names: Set[str] = set()
+
+    for fcurve in action.fcurves:
+        data_path: str = fcurve.data_path
+        match = pose_bone_re.match(data_path)
+        if not match:
+            continue
+
+        bone_name = match.group(1)
+
+        if bone_name in seen_bone_names:
+            continue
+        seen_bone_names.add(bone_name)
+
+        if flipped:
+            bone_name = flip_side_name(bone_name)
+
+        try:
+            pose_bone = pose.bones[bone_name]
+        except KeyError:
+            continue
+
+        pose_bone.bone.select = select
+
+
+_FLIP_SEPARATORS = set(". -_")
+
+# These are single-character replacements, others are handled differently.
+_FLIP_REPLACEMENTS = {
+    "l": "r",
+    "L": "R",
+    "r": "l",
+    "R": "L",
+}
+
+
+def flip_side_name(to_flip: str) -> str:
+    """Flip left and right indicators in the name.
+
+    Basically a Python implementation of BLI_string_flip_side_name.
+
+    >>> flip_side_name('bone_L.004')
+    'bone_R.004'
+    >>> flip_side_name('left_bone')
+    'right_bone'
+    >>> flip_side_name('Left_bone')
+    'Right_bone'
+    >>> flip_side_name('LEFT_bone')
+    'RIGHT_bone'
+    >>> flip_side_name('some.bone-RIGHT.004')
+    'some.bone-LEFT.004'
+    >>> flip_side_name('some.bone-right.004')
+    'some.bone-left.004'
+    >>> flip_side_name('some.bone-Right.004')
+    'some.bone-Left.004'
+    >>> flip_side_name('some.bone-LEFT.004')
+    'some.bone-RIGHT.004'
+    >>> flip_side_name('some.bone-left.004')
+    'some.bone-right.004'
+    >>> flip_side_name('some.bone-Left.004')
+    'some.bone-Right.004'
+    >>> flip_side_name('.004')
+    '.004'
+    >>> flip_side_name('L.004')
+    'R.004'
+    """
+    import string
+
+    if len(to_flip) < 3:
+        # we don't flip names like .R or .L
+        return to_flip
+
+    # We first check the case with a .### extension, let's find the last period.
+    number = ""
+    replace = to_flip
+    if to_flip[-1] in string.digits:
+        try:
+            index = to_flip.rindex(".")
+        except ValueError:
+            pass
+        else:
+            if to_flip[index + 1] in string.digits:
+                # TODO(Sybren): this doesn't handle "bone.1abc2" correctly.
+                number = to_flip[index:]
+                replace = to_flip[:index]
+
+    if not replace:
+        # Nothing left after the number, so no flips necessary.
+        return replace + number
+
+    if len(replace) == 1:
+        replace = _FLIP_REPLACEMENTS.get(replace, replace)
+        return replace + number
+
+    # First case; separator . - _ with extensions r R l L.
+    if replace[-2] in _FLIP_SEPARATORS and replace[-1] in _FLIP_REPLACEMENTS:
+        replace = replace[:-1] + _FLIP_REPLACEMENTS[replace[-1]]
+        return replace + number
+
+    # Second case; beginning with r R l L, with separator after it.
+    if replace[1] in _FLIP_SEPARATORS and replace[0] in _FLIP_REPLACEMENTS:
+        replace = _FLIP_REPLACEMENTS[replace[0]] + replace[1:]
+        return replace + number
+
+    lower = replace.lower()
+    prefix = suffix = ""
+    if lower.startswith("right"):
+        bit = replace[0:2]
+        if bit == "Ri":
+            prefix = "Left"
+        elif bit == "RI":
+            prefix = "LEFT"
+        else:
+            prefix = "left"
+        replace = replace[5:]
+    elif lower.startswith("left"):
+        bit = replace[0:2]
+        if bit == "Le":
+            prefix = "Right"
+        elif bit == "LE":
+            prefix = "RIGHT"
+        else:
+            prefix = "right"
+        replace = replace[4:]
+    elif lower.endswith("right"):
+        bit = replace[-5:-3]
+        if bit == "Ri":
+            suffix = "Left"
+        elif bit == "RI":
+            suffix = "LEFT"
+        else:
+            suffix = "left"
+        replace = replace[:-5]
+    elif lower.endswith("left"):
+        bit = replace[-4:-2]
+        if bit == "Le":
+            suffix = "Right"
+        elif bit == "LE":
+            suffix = "RIGHT"
+        else:
+            suffix = "right"
+        replace = replace[:-4]
+
+    return prefix + replace + suffix + number
+
+
+if __name__ == '__main__':
+    import doctest
+
+    print(f"Test result: {doctest.testmod()}")