diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 4c574b4c76c43eb1b796b9bb341edddc86e7e07f..bff43b233481b45a91c1e2ff53d1e235c6743838 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -83,6 +83,7 @@ if RAVEN_ENABLED:
 # Apps specific for this project go here.
 LOCAL_APPS = (
     'funkwhale_api.common',
+    'funkwhale_api.activity.apps.ActivityConfig',
     'funkwhale_api.users',  # custom users app
     # Your stuff: custom apps go here
     'funkwhale_api.instance',
diff --git a/api/funkwhale_api/activity/apps.py b/api/funkwhale_api/activity/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c66cbf50cf2542499ccce1fbdb262a13600324d
--- /dev/null
+++ b/api/funkwhale_api/activity/apps.py
@@ -0,0 +1,12 @@
+from django.apps import AppConfig, apps
+
+from . import record
+
+class ActivityConfig(AppConfig):
+    name = 'funkwhale_api.activity'
+
+    def ready(self):
+        super(ActivityConfig, self).ready()
+
+        app_names = [app.name for app in apps.app_configs.values()]
+        record.registry.autodiscover(app_names)
diff --git a/api/funkwhale_api/activity/record.py b/api/funkwhale_api/activity/record.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa55c0e85288318acd2f3d41ab02de9805eb8632
--- /dev/null
+++ b/api/funkwhale_api/activity/record.py
@@ -0,0 +1,38 @@
+import persisting_theory
+
+
+class ActivityRegistry(persisting_theory.Registry):
+    look_into = 'activities'
+
+    def _register_for_model(self, model, attr, value):
+        key = model._meta.label
+        d = self.setdefault(key, {'consumers': []})
+        d[attr] = value
+
+    def register_serializer(self, serializer_class):
+        model = serializer_class.Meta.model
+        self._register_for_model(model, 'serializer', serializer_class)
+        return serializer_class
+
+    def register_consumer(self, label):
+        def decorator(func):
+            consumers = self[label]['consumers']
+            if func not in consumers:
+                consumers.append(func)
+            return func
+        return decorator
+
+
+registry = ActivityRegistry()
+
+
+
+
+def send(obj):
+    conf = registry[obj.__class__._meta.label]
+    consumers = conf['consumers']
+    if not consumers:
+        return
+    serializer = conf['serializer'](obj)
+    for consumer in consumers:
+        consumer(data=serializer.data, obj=obj)
diff --git a/api/funkwhale_api/common/channels.py b/api/funkwhale_api/common/channels.py
new file mode 100644
index 0000000000000000000000000000000000000000..a2f85ee4e1df0a8d33cf67d80b56ed7886fdf1e8
--- /dev/null
+++ b/api/funkwhale_api/common/channels.py
@@ -0,0 +1,5 @@
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+
+channel_layer = get_channel_layer()
+group_send = async_to_sync(channel_layer.group_send)
diff --git a/api/tests/activity/test_record.py b/api/tests/activity/test_record.py
new file mode 100644
index 0000000000000000000000000000000000000000..41846ba6f109cdc94b24c4e1ab01fb9812065310
--- /dev/null
+++ b/api/tests/activity/test_record.py
@@ -0,0 +1,45 @@
+import pytest
+
+from django.db import models
+from rest_framework import serializers
+
+from funkwhale_api.activity import record
+
+
+class FakeModel(models.Model):
+    class Meta:
+        app_label = 'tests'
+
+
+class FakeSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = FakeModel
+        fields = ['id']
+
+
+
+
+def test_can_bind_serializer_to_model(activity_registry):
+    activity_registry.register_serializer(FakeSerializer)
+
+    assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer
+
+
+def test_can_bind_consumer_to_model(activity_registry):
+    activity_registry.register_serializer(FakeSerializer)
+    @activity_registry.register_consumer('tests.FakeModel')
+    def propagate(data, obj):
+        return True
+
+    assert activity_registry['tests.FakeModel']['consumers'] == [propagate]
+
+
+def test_record_object_calls_consumer(activity_registry, mocker):
+    activity_registry.register_serializer(FakeSerializer)
+    stub = mocker.stub()
+    activity_registry.register_consumer('tests.FakeModel')(stub)
+    o = FakeModel(id=1)
+    data = FakeSerializer(o).data
+    record.send(o)
+
+    stub.assert_called_once_with(data=data, obj=o)
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 10d7c323512c684ff495bda2a2a7a7ae581213f8..2d655f23f28dd2ea1ad56d4073df1147451c603c 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache
 from dynamic_preferences.registries import global_preferences_registry
 from rest_framework.test import APIClient
 
+from funkwhale_api.activity import record
 from funkwhale_api.taskapp import celery
 
 
@@ -81,3 +82,28 @@ def superuser_client(db, factories, client):
     setattr(client, 'user', user)
     yield client
     delattr(client, 'user')
+
+
+@pytest.fixture
+def activity_registry():
+    r = record.registry
+    state = list(record.registry.items())
+    yield record.registry
+    record.registry.clear()
+    for key, value in state:
+        record.registry[key] = value
+
+
+@pytest.fixture
+def activity_registry():
+    r = record.registry
+    state = list(record.registry.items())
+    yield record.registry
+    record.registry.clear()
+    for key, value in state:
+        record.registry[key] = value
+
+
+@pytest.fixture
+def activity_muted(activity_registry, mocker):
+    yield mocker.patch.object(record, 'send')