diff --git a/.editorconfig b/.editorconfig
index 5f9883604d6c33d89c24750247644fc373b82fc5..428085fdf04f43d7aa6c2850fb8d481580fcc5b0 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -4,6 +4,11 @@ root=true
 indent_style=space
 indent_size=2
 
+[*.{js,jsx,json,ts,tsx}]
+indent_style=space
+indent_size=2
+max_line_length=99
+
 [*.md]
 indent_style=space
 indent_size=2
diff --git a/.gitignore b/.gitignore
index 8ca9a958d732525663084ba0449dcbe73792e025..9ce22e8c2585618ad6065cbd6ede8e8d7e5a59e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ venv.bak/
 
 # buf
 gen
+dist
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 10c655f57e4492a6a9877054f96d5c616430594b..55f115ea9629f0e1fa82c77fd205b8b48a59ba68 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,4 +1,5 @@
 include:
+  - local: "/.gitlab/protobuf-generator.gitlab-ci.yml"
   - project: "uis/devops/continuous-delivery/ci-templates"
     file: "/auto-devops/common-pipeline.yml"
     ref: v6.7.2
diff --git a/.gitlab/protobuf-generator.gitlab-ci.yml b/.gitlab/protobuf-generator.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a7e1d40f5d809c70cbe3baa218c16dff42a33b75
--- /dev/null
+++ b/.gitlab/protobuf-generator.gitlab-ci.yml
@@ -0,0 +1,157 @@
+variables:
+  PROTOBUF_GENERATOR_BUF_CONFIGURATION_PATH: "buf.yaml"
+  PROTOBUF_GENERATOR_PROTO_FILES_DIR: "proto"
+
+  PROTOBUF_GENERATOR_BUF_IMAGE: "bufbuild/buf"
+
+  PROTOBUF_GENERATOR_BUF_PYTHON_CONFIGURATION_PATH: "buf.gen.yaml"
+  PROTOBUF_GENERATOR_PYTHON_OUTPUT_DIR: "gen/python"
+  PROTOBUF_GENERATOR_PYTHON_STATIC_FILES_DIR: "generator-static/python"
+  PROTOBUF_GENERATOR_PYTHON_BUILD_IMAGE: "python"
+
+  PROTOBUF_GENERATOR_PACKAGE_ARTIFACT_DIR: "protobuf_artifact_dir"
+
+  PROTOBUF_GENERATOR_PUBLISH_REF_NAME: $CI_DEFAULT_BRANCH
+
+.protobuf:generator-cli:
+  image:
+    name: $PROTOBUF_GENERATOR_BUF_IMAGE
+    entrypoint: [""]
+  variables:
+    PROTOBUF_GENERATOR_CMD: "/usr/local/bin/buf"
+  rules:
+    - if: $PROTOBUF_GENERATOR_DISABLED
+      when: never
+    - if: ($PROTOBUF_GENERATOR_BUF_CONFIGURATION_PATH == null) || ($PROTOBUF_GENERATOR_BUF_CONFIGURATION_PATH == "")
+      when: never
+    - if: ($PROTOBUF_GENERATOR_PROTO_FILES_DIR == null) || ($PROTOBUF_GENERATOR_PROTO_FILES_DIR == "")
+      when: never
+    - exists:
+        paths:
+          - $PROTOBUF_GENERATOR_BUF_CONFIGURATION_PATH
+          - $PROTOBUF_GENERATOR_BUF_GEN_CONFIGURATION_PATH
+          - $PROTOBUF_GENERATOR_PROTO_FILES_DIR
+    - if: $PROTOBUF_GENERATOR_ENABLED
+
+.protobuf:generator:base:
+  variables:
+    _template_file: "" # required
+    _artifact_dir: "" # required
+  rules:
+    - !reference [".protobuf:generator-cli", rules]
+
+.protobuf:generator:python:
+  extends: ".protobuf:generator:base"
+  variables:
+    _template_file: $PROTOBUF_GENERATOR_BUF_PYTHON_CONFIGURATION_PATH
+    _artifact_dir: $PROTOBUF_GENERATOR_PYTHON_OUTPUT_DIR
+  rules:
+    - if: $PROTOBUF_GENERATOR_PYTHON_DISABLED
+      when: never
+    - if: ($PROTOBUF_GENERATOR_BUF_PYTHON_CONFIGURATION_PATH == null) || ($PROTOBUF_GENERATOR_BUF_PYTHON_CONFIGURATION_PATH == "")
+      when: never
+    - exists:
+        paths:
+          - $PROTOBUF_GENERATOR_BUF_PYTHON_CONFIGURATION_PATH
+    - !reference [".protobuf:generator:base", rules]
+
+.protobuf:generate:
+  extends: [".protobuf:generator:base", ".protobuf:generator-cli"]
+  stage: build
+  variables:
+    _generator_args: "" # optional
+  before_script:
+    - mkdir -p $PROTOBUF_GENERATOR_PYTHON_OUTPUT_DIR
+  script:
+    - |-
+      $PROTOBUF_GENERATOR_CMD generate \
+        --template $_template_file
+  artifacts:
+    paths:
+      - $_artifact_dir
+  rules:
+    - !reference [".protobuf:generator-cli", rules]
+
+protobuf:generate:python:
+  extends: [".protobuf:generate", ".protobuf:generator:python"]
+
+.protobuf:build:base:
+  stage: build
+  variables:
+    _generated_dir: "" # required
+    _generator_slug: "" # required
+    _static_files_dir: "" # optional
+  before_script:
+    - |-
+      if [ "$_static_files_dir" != "" ]; then
+        cp -r $_static_files_dir/* $_generated_dir
+      fi
+  artifacts:
+    paths:
+      - $PROTOBUF_GENERATOR_PACKAGE_ARTIFACT_DIR/$_generator_slug
+  rules:
+    - !reference [".protobuf:generator:base", rules]
+
+.protobuf:build:python:
+  extends: ".protobuf:build:base"
+  variables:
+    _generator_slug: "python"
+    _generated_dir: $PROTOBUF_GENERATOR_PYTHON_OUTPUT_DIR
+    _static_files_dir: $PROTOBUF_GENERATOR_PYTHON_STATIC_FILES_DIR
+  image: $PROTOBUF_GENERATOR_PYTHON_BUILD_IMAGE
+  before_script:
+    - !reference [".protobuf:build:base", before_script]
+    - pip install build
+    - mkdir -p "$CI_PROJECT_DIR/$PROTOBUF_GENERATOR_PACKAGE_ARTIFACT_DIR/$_generator_slug/"
+  script:
+    - cd $_generated_dir
+    - python -m build
+    - mv dist/* "$CI_PROJECT_DIR/$PROTOBUF_GENERATOR_PACKAGE_ARTIFACT_DIR/$_generator_slug/"
+
+protobuf:build:python:
+  extends: [".protobuf:build:python"]
+  needs: ["protobuf:generate:python"]
+
+.protobuf:publish:base:
+  stage: deploy
+  variables:
+    _generator_slug: "" # required
+  before_script:
+    - cd "$PROTOBUF_GENERATOR_PACKAGE_ARTIFACT_DIR/$_generator_slug"
+  rules:
+    - if: $PROTOBUF_GENERATOR_PUBLISH_DISABLED
+      when: never
+    - if: $CI_COMMIT_REF_NAME != $PROTOBUF_GENERATOR_PUBLISH_REF_NAME
+      when: never
+    - !reference [".protobuf:generator:base", rules]
+
+.protobuf:publish:python:
+  extends: [".protobuf:publish:base"]
+  variables:
+    _generator_slug: "python"
+    _repository_url: ""
+  image: $PROTOBUF_GENERATOR_PYTHON_BUILD_IMAGE
+  before_script:
+    - !reference [".protobuf:publish:base", before_script]
+    - pip install twine
+  script:
+    - python -m twine upload --repository-url "$_repository_url" *
+  rules:
+    - if: $PROTOBUF_GENERATOR_PUBLISH_PYTHON_DISABLED
+      when: never
+    - !reference [".protobuf:publish:base", rules]
+
+.protobuf:publish:python:gitlab:
+  extends: [".protobuf:publish:python"]
+  variables:
+    TWINE_USERNAME: gitlab-ci-token
+    TWINE_PASSWORD: $CI_JOB_TOKEN
+    _repository_url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi"
+  rules:
+    - if: $PROTOBUF_GENERATOR_PUBLISH_PYTHON_GITLAB_DISABLED
+      when: never
+    - !reference [".protobuf:publish:python", rules]
+
+protobuf:publish:python:gitlab:
+  extends: [".protobuf:publish:python:gitlab"]
+  needs: ["protobuf:build:python"]
diff --git a/.release-it.json b/.release-it.json
index b75b801c45c416155340c2859a12613520aea62b..229dcd2ed2d7dfa6b13a34ebe60b97c5dc1c1bb9 100644
--- a/.release-it.json
+++ b/.release-it.json
@@ -4,7 +4,11 @@
   },
   "gitlab": {
     "release": true,
-    "releaseName": "${version}"
+    "releaseName": "${version}",
+    "useIdsForUrls": true,
+    "assets": [
+      "protobuf_artifact_dir/**"
+    ]
   },
   "plugins": {
     "@release-it/conventional-changelog": {
@@ -15,11 +19,23 @@
       }
     },
     "@release-it/bumper": {
-      "out": {
+      "in": {
         "file": "pyproject.toml",
         "type": "text/toml",
         "path": "tool.poetry.version"
-      }
+      },
+      "out": [
+        {
+          "file": "pyproject.toml",
+          "type": "text/toml",
+          "path": "tool.poetry.version"
+        },
+        {
+          "file": "generator-static/python/pyproject.toml",
+          "type": "text/toml",
+          "path": "tool.poetry.version"
+        }
+      ]
     }
   }
 }
diff --git a/README.md b/README.md
index 11ce4f9ff3cb31768aa2d835a3c38c21228f856d..9700ddb59dfef498f6e160a6f69d71ac8e01eb64 100644
--- a/README.md
+++ b/README.md
@@ -20,3 +20,12 @@ Or use `poe`:
 ```
 poe generate
 ```
+
+## Build Python library
+
+The Python library is built automatically in CI. However, if you want to run it
+locally, you can run:
+
+```
+poe python-build
+```
diff --git a/generator-static/python/pyproject.toml b/generator-static/python/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..7b7bbee9003d3811c389a843bb3c552da59026f7
--- /dev/null
+++ b/generator-static/python/pyproject.toml
@@ -0,0 +1,15 @@
+[tool.poetry]
+name = "message-schemas"
+version = "0.1.0"
+description = ""
+authors = [ ]
+packages = [
+    { include = "./**/*.py", to = "message_schemas" }
+]
+
+[tool.poetry.dependencies]
+betterproto = "^1.2.5"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/pyproject.toml b/pyproject.toml
index 28460f14c647d72f0b7bcd54370d6af78fb7402e..7a4d3a9283a6231e14675897e9081822a79274a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,18 @@ package-mode = false
 help = "Generate code with protoc plugins"
 cmd = "buf generate --template buf.gen.yaml"
 
+[tool.poe.tasks."build:python"]
+help = "Build the message schemas Python files, mimicking the process in CI"
+sequence = [
+    { ref = "generate" },
+    { shell = """
+cp -r ${POE_ROOT}/generator-static/python/* ${POE_ROOT}/gen/python
+cd ${POE_ROOT}/gen/python
+poetry build
+mv dist ${POE_ROOT}
+""" },
+]
+
 [tool.poe.tasks.fix]
 help = "Run pre-commit checks to fix formatting errors"
 cmd = "pre-commit run --all-files"