diff --git a/app/assets/javascripts/discourse/app/components/composer-action-title.js b/app/assets/javascripts/discourse/app/components/composer-action-title.js
index 851738c1586..c74d7089516 100644
--- a/app/assets/javascripts/discourse/app/components/composer-action-title.js
+++ b/app/assets/javascripts/discourse/app/components/composer-action-title.js
@@ -24,8 +24,15 @@ export default Component.extend({
   options: alias("model.replyOptions"),
   action: alias("model.action"),
 
-  @discourseComputed("options", "action")
+  // Note we update when some other attributes like tag/category change to allow
+  // text customizations to use those.
+  @discourseComputed("options", "action", "model.tags", "model.category")
   actionTitle(opts, action) {
+    let result = this.model.customizationFor("actionTitle");
+    if (result) {
+      return result;
+    }
+
     if (TITLES[action]) {
       return I18n.t(TITLES[action]);
     }
diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js
index 39418656ce1..56110de30bf 100644
--- a/app/assets/javascripts/discourse/app/controllers/composer.js
+++ b/app/assets/javascripts/discourse/app/controllers/composer.js
@@ -240,13 +240,22 @@ export default Controller.extend({
     return SAVE_ICONS[modelAction];
   },
 
+  // Note we update when some other attributes like tag/category change to allow
+  // text customizations to use those.
   @discourseComputed(
     "model.action",
     "isWhispering",
     "model.editConflict",
-    "model.privateMessage"
+    "model.privateMessage",
+    "model.tags",
+    "model.category"
   )
   saveLabel(modelAction, isWhispering, editConflict, privateMessage) {
+    let result = this.model.customizationFor("saveLabel");
+    if (result) {
+      return result;
+    }
+
     if (editConflict) {
       return "composer.overwrite_edit";
     } else if (isWhispering) {
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 3ecca9ea862..1c8c48ccebe 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -37,7 +37,9 @@ import {
   registerIconRenderer,
   replaceIcon,
 } from "discourse-common/lib/icon-library";
-import Composer from "discourse/models/composer";
+import Composer, {
+  registerCustomizationCallback,
+} from "discourse/models/composer";
 import DiscourseBanner from "discourse/components/discourse-banner";
 import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts";
 import Sharing from "discourse/lib/sharing";
@@ -87,7 +89,7 @@ import { addSearchSuggestion } from "discourse/widgets/search-menu-results";
 import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser";
 
 // If you add any methods to the API ensure you bump up this number
-const PLUGIN_API_VERSION = "0.12.3";
+const PLUGIN_API_VERSION = "0.12.5";
 
 // This helper prevents us from applying the same `modifyClass` over and over in test mode.
 function canModify(klass, type, resolverName, changes) {
@@ -1471,6 +1473,28 @@ class PluginApi {
       { ignoreMissing: true }
     );
   }
+
+  /**
+   * Support for customizing the composer text. By providing a callback. Callbacks should
+   * return `null` or `undefined` if you don't need a customization based on the current state.
+   *
+   * ```
+   * api.customizeComposerText({
+   *   actionTitle(model) {
+   *     if (model.hello) {
+   *        return "hello.world";
+   *     }
+   *   },
+   *
+   *   saveLabel(model) {
+   *     return "my.custom_save_label_key";
+   *   }
+   * })
+   *
+   */
+  customizeComposerText(callbacks) {
+    registerCustomizationCallback(callbacks);
+  }
 }
 
 // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number
diff --git a/app/assets/javascripts/discourse/app/models/composer.js b/app/assets/javascripts/discourse/app/models/composer.js
index c74a640eac5..4db89cc6677 100644
--- a/app/assets/javascripts/discourse/app/models/composer.js
+++ b/app/assets/javascripts/discourse/app/models/composer.js
@@ -24,6 +24,15 @@ import { isEmpty } from "@ember/utils";
 import { propertyNotEqual } from "discourse/lib/computed";
 import { throwAjaxError } from "discourse/lib/ajax-error";
 
+let _customizations = [];
+export function registerCustomizationCallback(cb) {
+  _customizations.push(cb);
+}
+
+export function resetComposerCustomizations() {
+  _customizations = [];
+}
+
 // The actions the composer can take
 export const CREATE_TOPIC = "createTopic",
   CREATE_SHARED_DRAFT = "createSharedDraft",
@@ -1305,6 +1314,18 @@ const Composer = RestModel.extend({
         this.set("draftSaving", false);
       });
   },
+
+  customizationFor(type) {
+    for (let i = 0; i < _customizations.length; i++) {
+      let cb = _customizations[i][type];
+      if (cb) {
+        let result = cb(this);
+        if (result) {
+          return result;
+        }
+      }
+    }
+  },
 });
 
 Composer.reopenClass({
diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js
index 5f612ef6c5d..e7d3ec797ba 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js
@@ -12,7 +12,8 @@ import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
 import { skip, test } from "qunit";
 import Draft from "discourse/models/draft";
 import I18n from "I18n";
-import { NEW_TOPIC_KEY } from "discourse/models/composer";
+import { CREATE_TOPIC, NEW_TOPIC_KEY } from "discourse/models/composer";
+import { withPluginApi } from "discourse/lib/plugin-api";
 import { Promise } from "rsvp";
 import { run } from "@ember/runloop";
 import selectKit from "discourse/tests/helpers/select-kit-helper";
@@ -1008,3 +1009,51 @@ acceptance("Composer", function (needs) {
     assert.notOk(exists(".discard-draft-modal .save-draft"));
   });
 });
+
+acceptance("Composer - Customizations", function (needs) {
+  needs.user();
+  needs.site({ can_tag_topics: true });
+
+  function customComposerAction(composer) {
+    return (
+      (composer.tags || []).indexOf("monkey") !== -1 &&
+      composer.action === CREATE_TOPIC
+    );
+  }
+
+  needs.hooks.beforeEach(() => {
+    withPluginApi("0.8.14", (api) => {
+      api.customizeComposerText({
+        actionTitle(model) {
+          if (customComposerAction(model)) {
+            return "custom text";
+          }
+        },
+
+        saveLabel(model) {
+          if (customComposerAction(model)) {
+            return "composer.emoji";
+          }
+        },
+      });
+    });
+  });
+
+  test("Supports text customization", async function (assert) {
+    await visit("/");
+    await click("#create-topic");
+    assert.equal(query(".action-title").innerText, I18n.t("topic.create_long"));
+    assert.equal(
+      query(".save-or-cancel button").innerText,
+      I18n.t("composer.create_topic")
+    );
+    const tags = selectKit(".mini-tag-chooser");
+    await tags.expand();
+    await tags.selectRowByValue("monkey");
+    assert.equal(query(".action-title").innerText, "custom text");
+    assert.equal(
+      query(".save-or-cancel button").innerText,
+      I18n.t("composer.emoji")
+    );
+  });
+});
diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js
index 3b2a062e4d5..8841b86e6c1 100644
--- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js
+++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js
@@ -37,6 +37,7 @@ import { resetUsernameDecorators } from "discourse/helpers/decorate-username-sel
 import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget";
 import { resetUserSearchCache } from "discourse/lib/user-search";
 import { resetCardClickListenerSelector } from "discourse/mixins/card-contents-base";
+import { resetComposerCustomizations } from "discourse/models/composer";
 import sessionFixtures from "discourse/tests/fixtures/session-fixtures";
 import { setTopicList } from "discourse/lib/topic-list-tracker";
 import sinon from "sinon";
@@ -280,6 +281,7 @@ export function acceptance(name, optionsOrCallback) {
       resetCustomPostMessageCallbacks();
       resetUserSearchCache();
       resetCardClickListenerSelector();
+      resetComposerCustomizations();
       resetPostMenuExtraButtons();
       clearNavItems();
       setTopicList(null);