diff --git a/app/assets/javascripts/discourse.js b/app/assets/javascripts/discourse.js
index 393c7bedc..5c609ff0c 100644
--- a/app/assets/javascripts/discourse.js
+++ b/app/assets/javascripts/discourse.js
@@ -117,12 +117,12 @@ window.Discourse = Ember.Application.createWithMixins(Discourse.Ajax, {
   start: function() {
 
     // Load any ES6 initializers
-    Ember.keys(requirejs._eak_seen).filter(function(key) {
-      return (/\/initializers\//).test(key);
-    }).forEach(function(moduleName) {
-      var module = require(moduleName, null, null, true);
-      if (!module) { throw new Error(moduleName + ' must export an initializer.'); }
-      Discourse.initializer(module.default);
+    Ember.keys(requirejs._eak_seen).forEach(function(key) {
+      if (/\/initializers\//.test(key)) {
+        var module = require(key, null, null, true);
+        if (!module) { throw new Error(key + ' must export an initializer.'); }
+        Discourse.initializer(module.default);
+      }
     });
 
     var initializers = this.initializers;
diff --git a/app/assets/javascripts/discourse/components/composer-text-area.js.es6 b/app/assets/javascripts/discourse/components/composer-text-area.js.es6
new file mode 100644
index 000000000..b6d66d2f0
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/composer-text-area.js.es6
@@ -0,0 +1,14 @@
+export default Ember.TextArea.extend({
+  placeholder: function() {
+    return I18n.t(this.get('placeholderKey'));
+  }.property('placeholderKey'),
+
+  _signalParentInsert: function() {
+    return this.get('parentView').childDidInsertElement(this);
+  }.on('didInsertElement'),
+
+  _signalParentDestroy: function() {
+    return this.get('parentView').childWillDestroyElement(this);
+  }.on('willDestroyElement')
+});
+
diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6
index fc5027a2f..5aaa5e85e 100644
--- a/app/assets/javascripts/discourse/controllers/composer.js.es6
+++ b/app/assets/javascripts/discourse/controllers/composer.js.es6
@@ -15,10 +15,9 @@ export default Discourse.Controller.extend({
   showEditReason: false,
   editReason: null,
 
-  init: function() {
-    this._super();
-    this.set('similarTopics', Em.A());
-  },
+  _initializeSimilar: function() {
+    this.set('similarTopics', []);
+  }.on('init'),
 
   actions: {
     // Toggle the reply view
@@ -45,7 +44,20 @@ export default Discourse.Controller.extend({
 
     displayEditReason: function() {
       this.set("showEditReason", true);
-    }
+    },
+
+    hitEsc: function() {
+      if (this.get('model.viewOpen')) {
+        this.shrink();
+      }
+    },
+
+    openIfDraft: function() {
+      if (this.get('model.viewDraft')) {
+        this.set('model.composeState', Discourse.Composer.OPEN);
+      }
+    },
+
   },
 
   updateDraftStatus: function() {
@@ -233,89 +245,74 @@ export default Discourse.Controller.extend({
   open: function(opts) {
     if (!opts) opts = {};
 
-    this.setProperties({
-      showEditReason: false,
-      editReason: null
-    });
-
-    var composerMessages = this.get('controllers.composerMessages');
-    composerMessages.reset();
-
-    var promise = opts.promise || Ember.Deferred.create();
-    opts.promise = promise;
-
     if (!opts.draftKey) {
       alert("composer was opened without a draft key");
       throw "composer opened without a proper draft key";
     }
 
-    // ensure we have a view now, without it transitions are going to be messed
-    var view = this.get('view');
-    var self = this;
-    if (!view) {
+    var composerMessages = this.get('controllers.composerMessages'),
+        self = this,
+        composerModel = this.get('model');
 
-      // TODO: We should refactor how composer is inserted. It should probably use a
-      // {{render}} and then the controller and view will be wired up automatically.
-      var appView = Discourse.__container__.lookup('view:application');
-      view = appView.createChildView(Discourse.ComposerView, {controller: this});
-      view.appendTo($('#main'));
-      this.set('view', view);
+    this.setProperties({ showEditReason: false, editReason: null });
+    composerMessages.reset();
+    this.set('view', this.container.lookup('view:composer'));
 
-      // the next runloop is too soon, need to get the control rendered and then
-      //  we need to change stuff, otherwise css animations don't kick in
-      Em.run.next(function() {
-        Em.run.next(function() {
-          self.open(opts);
-        });
-      });
-      return promise;
-    }
-
-    var composer = this.get('model');
-    if (composer && opts.draftKey !== composer.draftKey && composer.composeState === Discourse.Composer.DRAFT) {
+    // If we want a different draft than the current composer, close it and clear our model.
+    if (composerModel && opts.draftKey !== composerModel.draftKey &&
+        composerModel.composeState === Discourse.Composer.DRAFT) {
       this.close();
-      composer = null;
+      composerModel = null;
     }
 
-    if (composer && !opts.tested && composer.get('replyDirty')) {
-      if (composer.composeState === Discourse.Composer.DRAFT && composer.draftKey === opts.draftKey && composer.action === opts.action) {
-        composer.set('composeState', Discourse.Composer.OPEN);
-        promise.resolve();
-        return promise;
-      } else {
-        opts.tested = true;
-        if (!opts.ignoreIfChanged) {
-          this.cancelComposer().then(function() { self.open(opts); }).catch(function() { return promise.reject(); });
+    return new Ember.RSVP.Promise(function(resolve, reject) {
+      if (composerModel && composerModel.get('replyDirty')) {
+        if (composerModel.get('composeState') === Discourse.Composer.DRAFT &&
+            composerModel.get('draftKey') === opts.draftKey &&
+            composerModel.action === opts.action) {
+
+            // If it's the same draft, just open it up again.
+            composerModel.set('composeState', Discourse.Composer.OPEN);
+            return resolve();
+        } else {
+          // If it's a different draft, cancel it and try opening again.
+          return self.cancelComposer().then(function() {
+            return self.open(opts);
+          }).then(resolve, reject);
         }
-        return promise;
       }
-    }
 
-    // we need a draft sequence, without it drafts are bust
-    if (opts.draftSequence === void 0) {
-      Discourse.Draft.get(opts.draftKey).then(function(data) {
-        opts.draftSequence = data.draft_sequence;
-        opts.draft = data.draft;
-        return self.open(opts);
-      });
-      return promise;
-    }
+      // we need a draft sequence for the composer to work
+      if (opts.draftSequence === void 0) {
+        return Discourse.Draft.get(opts.draftKey).then(function(data) {
+          opts.draftSequence = data.draft_sequence;
+          opts.draft = data.draft;
+          self._setModel(composerModel, opts);
+        }).then(resolve, reject);
+      }
 
+      self._setModel(composerModel, opts);
+      resolve();
+    });
+  },
+
+  // Given a potential instance and options, set the model for this composer.
+  _setModel: function(composerModel, opts) {
     if (opts.draft) {
-      composer = Discourse.Composer.loadDraft(opts.draftKey, opts.draftSequence, opts.draft);
-      if (composer) {
-        composer.set('topic', opts.topic);
+      composerModel = Discourse.Composer.loadDraft(opts.draftKey, opts.draftSequence, opts.draft);
+      if (composerModel) {
+        composerModel.set('topic', opts.topic);
       }
     } else {
-      composer = composer || Discourse.Composer.create();
-      composer.open(opts);
+      composerModel = composerModel || Discourse.Composer.create();
+      composerModel.open(opts);
     }
 
-    this.set('model', composer);
-    composer.set('composeState', Discourse.Composer.OPEN);
-    composerMessages.queryFor(this.get('model'));
-    promise.resolve();
-    return promise;
+    this.set('model', composerModel);
+    composerModel.set('composeState', Discourse.Composer.OPEN);
+
+    var composerMessages = this.get('controllers.composerMessages');
+    composerMessages.queryFor(composerModel);
   },
 
   // View a new reply we've made
@@ -356,11 +353,6 @@ export default Discourse.Controller.extend({
     });
   },
 
-  openIfDraft: function() {
-    if (this.get('model.viewDraft')) {
-      this.set('model.composeState', Discourse.Composer.OPEN);
-    }
-  },
 
   shrink: function() {
     if (this.get('model.replyDirty')) {
@@ -388,13 +380,6 @@ export default Discourse.Controller.extend({
     $('#wmd-input').autocomplete({ cancel: true });
   },
 
-  // ESC key hit
-  hitEsc: function() {
-    if (this.get('model.viewOpen')) {
-      this.shrink();
-    }
-  },
-
   showOptions: function() {
     var _ref;
     return (_ref = this.get('controllers.modal')) ? _ref.show(Discourse.ArchetypeOptionsModalView.create({
diff --git a/app/assets/javascripts/discourse/ember/resolver.js b/app/assets/javascripts/discourse/ember/resolver.js
index 579405560..831fc397b 100644
--- a/app/assets/javascripts/discourse/ember/resolver.js
+++ b/app/assets/javascripts/discourse/ember/resolver.js
@@ -65,6 +65,10 @@ Discourse.Resolver = Ember.DefaultResolver.extend({
     return this.customResolve(parsedName) || this._super(parsedName);
   },
 
+  resolveHelper: function(parsedName) {
+    return this.customResolve(parsedName) || this._super(parsedName);
+  },
+
   resolveController: function(parsedName) {
     return this.customResolve(parsedName) || this._super(parsedName);
   },
diff --git a/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6 b/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6
new file mode 100644
index 000000000..e0f95f575
--- /dev/null
+++ b/app/assets/javascripts/discourse/helpers/plugin-outlet.js.es6
@@ -0,0 +1,84 @@
+/**
+   A plugin outlet is an extension point for templates where other templates can
+   be inserted by plugins.
+
+   If you handlebars template has:
+
+   ```handlebars
+     {{plugin-outlet "evil-trout"}}
+   ```
+
+   Then any handlebars files you create in the `connectors/evil-trout` directory
+   will automatically be appended. For example:
+
+   plugins/hello/assets/javascripts/discourse/templates/connectors/evil-trout/hello.handlebars
+
+   With the contents:
+
+   ```handlebars
+     <b>Hello World</b>
+   ```
+
+   Will insert <b>Hello World</b> at that point in the template.
+
+   Optionally you can also define a view class for the outlet as:
+
+   plugins/hello/assets/javascripts/discourse/views/connectors/evil-trout/hello.js.es6
+
+   And it will be wired up automatically.
+
+**/
+
+var _connectorCache;
+
+function findOutlets(collection, callback) {
+  Ember.keys(collection).forEach(function(i) {
+    if (i.indexOf("/connectors/") !== -1) {
+      var segments = i.split("/"),
+          outletName = segments[segments.length-2],
+          uniqueName = segments[segments.length-1];
+
+      callback(outletName, i, uniqueName);
+    }
+  });
+}
+
+function buildConnectorCache() {
+  _connectorCache = {};
+
+  var uniqueViews = {};
+  findOutlets(requirejs._eak_seen, function(outletName, idx, uniqueName) {
+    _connectorCache[outletName] = _connectorCache[outletName] || [];
+
+    var viewClass = require(idx, null, null, true).default;
+    uniqueViews[uniqueName] = viewClass;
+    _connectorCache[outletName].pushObject(viewClass);
+  });
+
+  findOutlets(Ember.TEMPLATES, function(outletName, idx, uniqueName) {
+    _connectorCache[outletName] = _connectorCache[outletName] || [];
+
+    var mixin = {templateName: idx.replace('javascripts/', '')},
+        viewClass = uniqueViews[uniqueName];
+
+    if (viewClass) {
+      // We are going to add it back with the proper template
+      _connectorCache[outletName].removeObject(viewClass);
+    } else {
+      viewClass = Em.View;
+    }
+    _connectorCache[outletName].pushObject(viewClass.extend(mixin));
+  });
+
+}
+
+export default function(connectionName, options) {
+  if (!_connectorCache) { buildConnectorCache(); }
+
+  if (_connectorCache[connectionName]) {
+    var CustomContainerView = Ember.ContainerView.extend({
+      childViews: _connectorCache[connectionName].map(function(vc) { return vc.create(); })
+    });
+    return Ember.Handlebars.helpers.view.call(this, CustomContainerView, options);
+  }
+}
diff --git a/app/assets/javascripts/discourse/templates/application.js.handlebars b/app/assets/javascripts/discourse/templates/application.js.handlebars
index 43e5273bd..8319dc7b4 100644
--- a/app/assets/javascripts/discourse/templates/application.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/application.js.handlebars
@@ -5,3 +5,4 @@
 </div>
 
 {{render "modal"}}
+{{render "composer"}}
diff --git a/app/assets/javascripts/discourse/templates/composer.js.handlebars b/app/assets/javascripts/discourse/templates/composer.js.handlebars
index ba4942542..9aff0cb15 100644
--- a/app/assets/javascripts/discourse/templates/composer.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/composer.js.handlebars
@@ -9,6 +9,8 @@
 
     {{#if model.viewOpen}}
       <div class='control-row reply-area'>
+        {{plugin-outlet "composer-open"}}
+
         <div class='reply-to'>
           {{{model.actionTitle}}}:
           {{#if canEdit}}
@@ -57,7 +59,7 @@
           <div class='textarea-wrapper'>
             <div class='wmd-button-bar' id='wmd-button-bar'></div>
             <div id='wmd-preview-scroller'></div>
-            {{view Discourse.NotifyingTextArea parentBinding="view" tabindex="3" valueBinding="model.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}}
+            {{composer-text-area tabindex="3" value=model.reply id="wmd-input" placeholderKey="composer.reply_placeholder"}}
             {{popupInputTip validation=view.replyValidation shownAt=view.showReplyTip}}
           </div>
           <div class='preview-wrapper'>
@@ -75,7 +77,7 @@
                   <a {{action showUploadSelector view}} class='mobile-file-upload'>{{i18n upload}}</a>
                 {{/if}}
               {{/if}}
-              <div id='draft-status'></div>
+              <div id='draft-status'>{{model.draftStatus}}</div>
             </div>
           {{/if}}
         </div>
diff --git a/app/assets/javascripts/discourse/templates/topic.js.handlebars b/app/assets/javascripts/discourse/templates/topic.js.handlebars
index dd0888909..351466492 100644
--- a/app/assets/javascripts/discourse/templates/topic.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/topic.js.handlebars
@@ -48,6 +48,7 @@
               {{/if}}
             </h1>
           {{/if}}
+          {{plugin-outlet "topic-title"}}
         </div>
       </div>
     </div>
diff --git a/app/assets/javascripts/discourse/views/composer/composer_view.js b/app/assets/javascripts/discourse/views/composer/composer_view.js
index 90e9ef8c9..657feff00 100644
--- a/app/assets/javascripts/discourse/views/composer/composer_view.js
+++ b/app/assets/javascripts/discourse/views/composer/composer_view.js
@@ -26,15 +26,9 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
   content: Em.computed.alias('model'),
 
   composeState: function() {
-    var state = this.get('model.composeState');
-    if (state) return state;
-    return Discourse.Composer.CLOSED;
+    return this.get('model.composeState') || Discourse.Composer.CLOSED;
   }.property('model.composeState'),
 
-  draftStatus: function() {
-    $('#draft-status').text(this.get('model.draftStatus') || "");
-  }.observes('model.draftStatus'),
-
   // Disable fields when we're loading
   loadingChanged: function() {
     if (this.get('loading')) {
@@ -48,7 +42,6 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
     return this.present('controller.createdPost') ? 'created-post' : null;
   }.property('model.createdPost'),
 
-
   refreshPreview: Discourse.debounce(function() {
     if (this.editor) {
       this.editor.refreshPreview();
@@ -101,7 +94,7 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
   keyDown: function(e) {
     if (e.which === 27) {
       // ESC
-      this.get('controller').hitEsc();
+      this.get('controller').send('hitEsc');
       return false;
     } else if (e.which === 13 && (e.ctrlKey || e.metaKey)) {
       // CTRL+ENTER or CMD+ENTER
@@ -110,12 +103,12 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
     }
   },
 
-  didInsertElement: function() {
+  _enableResizing: function() {
     var $replyControl = $('#reply-control');
     $replyControl.DivResizer({ resize: this.resize, onDrag: this.movePanels });
     Discourse.TransitionHelper.after($replyControl, this.resize);
     this.ensureMaximumDimensionForImagesInPreview();
-  },
+  }.on('didInsertElement'),
 
   ensureMaximumDimensionForImagesInPreview: function() {
     // This enforce maximum dimensions of images in the preview according
@@ -132,7 +125,7 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
   },
 
   click: function() {
-    this.get('controller').openIfDraft();
+    this.get('controller').send('openIfDraft');
   },
 
   // Called after the preview renders. Debounced for performance
@@ -487,19 +480,4 @@ Discourse.ComposerView = Discourse.View.extend(Ember.Evented, {
   }
 });
 
-// not sure if this is the right way, keeping here for now, we could use a mixin perhaps
-Discourse.NotifyingTextArea = Ember.TextArea.extend({
-  placeholder: function() {
-    return I18n.t(this.get('placeholderKey'));
-  }.property('placeholderKey'),
-
-  didInsertElement: function() {
-    return this.get('parent').childDidInsertElement(this);
-  },
-
-  willDestroyElement: function() {
-    return this.get('parent').childWillDestroyElement(this);
-  }
-});
-
 RSVP.EventTarget.mixin(Discourse.ComposerView);
diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb
index 55fffea11..2b8ec9218 100644
--- a/lib/plugin/instance.rb
+++ b/lib/plugin/instance.rb
@@ -23,6 +23,15 @@ class Plugin::Instance
     @metadata = metadata
     @path = path
     @assets = []
+
+    # Automatically include all ES6 JS files
+    if @path
+      dir = File.dirname(@path)
+      Dir.glob("#{dir}/assets/javascripts/**/*.js.es6") do |f|
+        relative = f.sub("#{dir}/assets/", "")
+        register_asset(relative)
+      end
+    end
   end
 
   def name