refactor: migrate workflows feature to the new architecture

Bug: twpowertools:176
Change-Id: Ib0af4cd828577f2a399be93264d45fdfddbad9b0
diff --git a/src/features/workflows/core/common.js b/src/features/workflows/core/common.js
new file mode 100644
index 0000000..6d6ed03
--- /dev/null
+++ b/src/features/workflows/core/common.js
@@ -0,0 +1,16 @@
+// Source: https://stackoverflow.com/a/66046176
+export const arrayBufferToBase64 = async (data) => {
+  // Use a FileReader to generate a base64 data URI
+  const base64url = await new Promise((r) => {
+    const reader = new FileReader();
+    reader.onload = () => r(reader.result);
+    reader.readAsDataURL(new Blob([data]));
+  });
+
+  /*
+  The result looks like
+  "data:application/octet-stream;base64,<your base64 data>",
+  so we split off the beginning:
+  */
+  return base64url.split(',', 2)[1]
+}
diff --git a/src/features/workflows/core/communityConsole/actionRunners/attribute.js b/src/features/workflows/core/communityConsole/actionRunners/attribute.js
new file mode 100644
index 0000000..a542280
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/actionRunners/attribute.js
@@ -0,0 +1,20 @@
+import {CCApi} from '../../../../../common/api.js';
+import {getAuthUser} from '../../../../../common/communityConsoleUtils.js';
+
+export default class AttributeRunner {
+  async execute(attributeAction, thread) {
+    if (!attributeAction) {
+      throw new Error(
+          'The workflow is malformed. The attribute action is missing.');
+    }
+    const action = attributeAction.getAttributeAction();
+
+    return await CCApi(
+        'SetThreadAttribute', {
+          1: thread.forumId,
+          2: thread.threadId,
+          3: action,
+        },
+        /* authenticated = */ true, getAuthUser());
+  }
+}
diff --git a/src/features/workflows/core/communityConsole/actionRunners/readState.js b/src/features/workflows/core/communityConsole/actionRunners/readState.js
new file mode 100644
index 0000000..f24f7c6
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/actionRunners/readState.js
@@ -0,0 +1,21 @@
+import {CCApi} from '../../../../../common/api.js';
+import {getAuthUser} from '../../../../../common/communityConsoleUtils.js';
+
+export default class ReadStateRunner {
+  execute(readState, thread) {
+    // Although this should in theory be the last message ID, it seems like
+    // setting 0 marks the entire thread as read anyways.
+    const lastMessageId = readState ? '0' : '-1';
+
+    return CCApi(
+        'SetUserReadStateBulk', {
+          // bulkItem:
+          1: [{
+            1: thread.forumId,
+            2: thread.threadId,
+            3: lastMessageId,
+          }],
+        },
+        /* authenticated = */ true, getAuthUser());
+  }
+}
diff --git a/src/features/workflows/core/communityConsole/actionRunners/replyWithCR.js b/src/features/workflows/core/communityConsole/actionRunners/replyWithCR.js
new file mode 100644
index 0000000..347d5c4
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/actionRunners/replyWithCR.js
@@ -0,0 +1,80 @@
+import {CCApi} from '../../../../../common/api.js';
+import {getAuthUser} from '../../../../../common/communityConsoleUtils.js';
+
+const kPiiScanType_ScanNone = 0;
+const kType_Reply = 1;
+const kType_RecommendedAnswer = 3;
+const kPostMethodCommunityConsole = 4;
+
+const kVariablesRegex = /\$([A-Za-z_]+)/g;
+
+export default class CRRunner {
+  constructor() {
+    this._CRs = [];
+    this._haveCRsBeenLoaded = false;
+  }
+
+  loadCRs() {
+    return CCApi(
+               'ListCannedResponses', {}, /* authenticated = */ true,
+               getAuthUser())
+        .then(res => {
+          this._CRs = res?.[1] ?? [];
+          this._haveCRsBeenLoaded = true;
+        });
+  }
+
+  _getCRPayload(id) {
+    let maybeLoadCRsPromise;
+    if (!this._haveCRsBeenLoaded)
+      maybeLoadCRsPromise = this.loadCRs();
+    else
+      maybeLoadCRsPromise = Promise.resolve();
+
+    return maybeLoadCRsPromise.then(() => {
+      let cr = this._CRs.find(cr => cr?.[1]?.[1] == id);
+      if (!cr) throw new Error(`Couldn't find CR with id ${id}.`);
+      return cr?.[3];
+    });
+  }
+
+  _templateSubstitute(payload, thread) {
+    if (!payload.match(kVariablesRegex)) return Promise.resolve(payload);
+
+    return thread.loadThreadDetails().then(() => {
+      return payload.replaceAll(kVariablesRegex, (_, p1) => {
+        return thread?.[p1] ?? '';
+      });
+    });
+  }
+
+  execute(action, thread) {
+    let crId = action?.getCannedResponseId?.();
+    if (!crId)
+      return Promise.reject(
+          new Error('The action doesn\'t contain a valid CR id.'));
+
+    return this._getCRPayload(crId)
+        .then(payload => this._templateSubstitute(payload, thread))
+        .then(payload => {
+          let subscribe = action?.getSubscribe?.() ?? false;
+          let markAsAnswer = action?.getMarkAsAnswer?.() ?? false;
+          return CCApi(
+              'CreateMessage', {
+                1: thread.forumId,
+                2: thread.threadId,
+                // message
+                3: {
+                  4: payload,
+                  6: {
+                    1: markAsAnswer ? kType_RecommendedAnswer : kType_Reply,
+                  },
+                  11: kPostMethodCommunityConsole,
+                },
+                4: subscribe,
+                6: kPiiScanType_ScanNone,
+              },
+              /* authenticated = */ true, getAuthUser());
+        });
+  }
+}
diff --git a/src/features/workflows/core/communityConsole/components/TwptCRImportButton.js b/src/features/workflows/core/communityConsole/components/TwptCRImportButton.js
new file mode 100644
index 0000000..0431de7
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/components/TwptCRImportButton.js
@@ -0,0 +1,41 @@
+import '@material/web/button/outlined-button.js';
+import '@material/web/icon/icon.js';
+
+import {html, LitElement} from 'lit';
+
+import {SHARED_MD3_STYLES} from '../../../../../common/styles/md3.js';
+
+export default class TwptCRImportButton extends LitElement {
+  static properties = {
+    cannedResponseId: {type: String},
+    selected: {type: Boolean},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+  ];
+
+  render() {
+    const icon = this.selected ? 'done' : 'post_add';
+    const label = this.selected ? 'Selected' : 'Select';
+
+    return html`
+      <md-outlined-button
+          ?disabled=${this.selected}
+          @click=${this._importCR}>
+        <md-icon slot="icon">${icon}</md-icon>
+        ${label}
+      </md-outlined-button>
+    `;
+  }
+
+  _importCR() {
+    window.opener?.postMessage?.(
+        {
+          action: 'importCannedResponse',
+          cannedResponseId: this.cannedResponseId,
+        },
+        '*');
+  }
+}
+window.customElements.define('twpt-cr-import-button', TwptCRImportButton);
diff --git a/src/features/workflows/core/communityConsole/components/TwptConfirmDialog.js b/src/features/workflows/core/communityConsole/components/TwptConfirmDialog.js
new file mode 100644
index 0000000..bf80658
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/components/TwptConfirmDialog.js
@@ -0,0 +1,74 @@
+import '@material/web/dialog/dialog.js';
+import '@material/web/button/filled-button.js';
+import '@material/web/button/text-button.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../../common/styles/md3.js';
+
+export default class TwptConfirmDialog extends LitElement {
+  static properties = {
+    open: {type: Boolean},
+    workflow: {type: Object},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+      :host {
+        --mdc-dialog-content-ink-color: var(--mdc-theme-on-surface, #000);
+        --mdc-dialog-z-index: 200;
+      }
+
+      .workflow {
+        font-weight: 500;
+      }
+    `,
+  ];
+
+  constructor() {
+    super();
+    this.open = false;
+  }
+
+  render() {
+    return html`
+      <md-dialog
+          ?open=${this.open}
+          @open=${this._openingDialog}
+          @close=${this._closingDialog}>
+        <div slot="content">
+          Are you sure you want to run workflow
+          <span class="workflow">${this.workflow?.getName?.()}</span> for all
+          the selected threads?
+        </div>
+        <div slot="actions">
+          <md-text-button
+              @click=${() => this.open = false}>
+            Cancel
+          </md-text-button>
+          <md-filled-button
+              @click=${this._dispatchConfirmEvent}>
+            Run workflow
+          </md-filled-button>
+        </div>
+      </md-dialog>
+    `;
+  }
+
+  _openingDialog() {
+    this.open = true;
+  }
+
+  _closingDialog() {
+    this.open = false;
+  }
+
+  _dispatchConfirmEvent() {
+    const e = new Event('confirm');
+    this.dispatchEvent(e);
+    this.open = false;
+  }
+}
+window.customElements.define('twpt-confirm-dialog', TwptConfirmDialog);
diff --git a/src/features/workflows/core/communityConsole/components/TwptWorkflowDialog.js b/src/features/workflows/core/communityConsole/components/TwptWorkflowDialog.js
new file mode 100644
index 0000000..9e27edf
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/components/TwptWorkflowDialog.js
@@ -0,0 +1,85 @@
+import '@material/web/dialog/dialog.js';
+import '@material/web/button/text-button.js';
+import './TwptWorkflowProgress.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../../common/styles/md3.js';
+import WorkflowRunner from '../runner.js';
+
+export default class TwptWorkflowDialog extends LitElement {
+  static properties = {
+    open: {type: Boolean},
+    workflow: {type: Object},
+    _runner: {type: Object, state: true},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+      :host {
+        --mdc-dialog-content-ink-color: var(--mdc-theme-on-surface, #000);
+        --mdc-dialog-z-index: 200;
+      }
+
+      .workflow-name {
+        font-weight: 500;
+      }
+    `,
+  ];
+
+  progressRef = createRef();
+
+  constructor() {
+    super();
+    this.open = false;
+  }
+
+  render() {
+    return html`
+      <md-dialog
+          ?open=${this.open}
+          @open=${this._openingDialog}
+          @close=${this._closingDialog}>
+        <div slot="headline">
+          ${'Running ' + this.workflow?.getName?.() + '...'}
+        </div>
+        <div slot="content">
+          <twpt-workflow-progress ${ref(this.progressRef)}
+              .workflow=${this.workflow}
+              currentThread=${this._runner?.currentThreadIndex}
+              numThreads=${this._runner?.numThreads}
+              currentAction=${this._runner?.currentActionIndex}
+              status=${this._runner?.status}>
+          </twpt-workflow-progress>
+        </div>
+
+        <div slot="actions">
+          <md-text-button
+              ?disabled=${this._runner?.status !== 'finished'}
+              slot="primaryAction"
+              @click=${() => this.open = false}>
+            Close
+          </md-text-button>
+        </div>
+      </md-dialog>
+    `;
+  }
+
+  start() {
+    this._runner =
+        new WorkflowRunner(this.workflow, () => this.requestUpdate());
+    this._runner.start();
+    this.open = true;
+  }
+
+  _openingDialog() {
+    this.open = true;
+  }
+
+  _closingDialog() {
+    this.open = false;
+  }
+}
+window.customElements.define('twpt-workflow-dialog', TwptWorkflowDialog);
diff --git a/src/features/workflows/core/communityConsole/components/TwptWorkflowProgress.js b/src/features/workflows/core/communityConsole/components/TwptWorkflowProgress.js
new file mode 100644
index 0000000..587db8f
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/components/TwptWorkflowProgress.js
@@ -0,0 +1,75 @@
+import '@material/mwc-dialog/mwc-dialog.js';
+import '@material/web/button/filled-button.js';
+import '@material/web/button/text-button.js';
+
+import '../../manager/components/ActionEditor.js';
+
+import {css, html, LitElement} from 'lit';
+import {map} from 'lit/directives/map.js';
+
+import {SHARED_MD3_STYLES} from '../../../../../common/styles/md3.js';
+
+export default class TwptWorkflowProgress extends LitElement {
+  static properties = {
+    workflow: {type: Object},
+    currentThread: {type: Number},
+    numThreads: {type: Number},
+    currentAction: {type: Number},
+    status: {type: String},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+      .progressbar-container {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+      }
+    `,
+  ];
+
+  renderThreadProgress() {
+    // @TODO: Improve this UI when the actions section is complete
+    return html`
+      <div class="progressbar-container">
+        <progress value=${this.currentThread + 1} max=${
+        this.numThreads}></progress>
+        <span>Thread ${this.currentThread + 1}/${this.numThreads}</span>
+      </div>
+      <p style="color: gray;">(Debug information) Status: ${this.status}</p>
+    `;
+  }
+
+  renderActions() {
+    const actions = this.workflow?.getActionsList?.() ?? [];
+    return map(actions, (action, i) => {
+      let status;
+      if (i > this.currentAction)
+        status = 'idle';
+      else if (i == this.currentAction && this.status == 'running')
+        status = 'running';
+      else if (i == this.currentAction && this.status == 'error')
+        status = 'error';
+      else
+        status = 'done';
+
+      return html`
+        <wf-action-editor
+            .action=${action}
+            readOnly
+            step=${i + 1}
+            status=${status}>
+        </wf-action-editor>
+      `;
+    });
+  }
+
+  render() {
+    return [
+      this.renderThreadProgress(),
+      this.renderActions(),
+    ];
+  }
+}
+window.customElements.define('twpt-workflow-progress', TwptWorkflowProgress);
diff --git a/src/features/workflows/core/communityConsole/components/TwptWorkflowsMenu.js b/src/features/workflows/core/communityConsole/components/TwptWorkflowsMenu.js
new file mode 100644
index 0000000..05fb216
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/components/TwptWorkflowsMenu.js
@@ -0,0 +1,134 @@
+import '@material/web/divider/divider.js';
+import '@material/web/icon/icon.js';
+import '@material/web/iconbutton/icon-button.js';
+import '@material/web/menu/menu.js';
+import '@material/web/menu/menu-item.js';
+
+import consoleCommonStyles from '!!raw-loader!../../../../../static/css/common/console.css';
+
+import {css, html, LitElement, nothing, unsafeCSS} from 'lit';
+import {map} from 'lit/directives/map.js';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../../common/styles/md3.js';
+
+export default class TwptWorkflowsMenu extends LitElement {
+  static properties = {
+    workflows: {type: Object},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`${unsafeCSS(consoleCommonStyles)}`,
+    css`
+      .workflows-menu {
+        --md-menu-item-label-text-size: 14px;
+      }
+
+      .workflow-item {
+        --md-menu-item-one-line-container-height: 48px;
+
+        min-width: 250px;
+      }
+
+      /* Custom styles to override the common button with badge styles */
+      .TWPT-btn--with-badge {
+        padding-bottom: 0;
+      }
+
+      .TWPT-btn--with-badge .TWPT-badge {
+        bottom: 4px;
+        right: 2px;
+      }
+    `,
+  ];
+
+  menuRef = createRef();
+
+  renderWorkflowItems() {
+    if (!this.workflows) return nothing;
+    if (this.workflows?.length == 0)
+      return html`
+        <md-menu-item disabled>
+          <span class="workflow-item" slot="start">
+            No workflows
+          </span>
+        </md-menu-item>
+      `;
+    return map(this.workflows, w => html`
+      <md-menu-item
+          class="workflow-item"
+          @click="${() => this._dispatchSelectEvent(w.uuid)}">
+        <span slot="start">
+          ${w.proto.getName()}
+        </span>
+      </md-menu-item>
+    `);
+  }
+
+  renderMenuItems() {
+    return [
+      this.renderWorkflowItems(),
+      html`
+        <md-divider></md-divider>
+        <md-menu-item
+            class="workflow-item"
+            @click="${() => this._openWorkflowManager()}">
+          <span slot="start">
+            Manage workflows...
+          </span>
+        </md-menu-item>
+      `,
+    ];
+  }
+
+  // Based on createExtBadge() in ../../utils/common.js.
+  renderBadge() {
+    return html`
+      <div class="TWPT-badge">
+        <md-icon>repeat</md-icon>
+      </div>
+    `;
+  }
+
+  render() {
+    return html`
+      <span style="position: relative;">
+        <div
+            id="workflows-menu-anchor"
+            class="TWPT-btn--with-badge"
+            @click="${this._toggleMenu}">
+          <md-icon-button>
+            <md-icon>more_vert</md-icon>
+          </md-icon-button>
+          ${this.renderBadge()}
+        </div>
+        <md-menu ${ref(this.menuRef)}
+            class="workflows-menu"
+            anchor="workflows-menu-anchor">
+          ${this.renderMenuItems()}
+        </md-menu>
+      </span>
+    `;
+  }
+
+  _dispatchSelectEvent(uuid) {
+    const e = new CustomEvent('select', {
+      detail: {
+        selectedWorkflowUuid: uuid,
+      },
+    });
+    this.dispatchEvent(e);
+    this.menuRef.value.open = false;
+  }
+
+  _toggleMenu() {
+    this.menuRef.value.open = !this.menuRef.value.open;
+  }
+
+  _openWorkflowManager() {
+    const e = new Event('twpt-open-workflow-manager');
+    document.dispatchEvent(e);
+  }
+}
+window.customElements.define('twpt-workflows-menu', TwptWorkflowsMenu);
diff --git a/src/features/workflows/core/communityConsole/components/index.js b/src/features/workflows/core/communityConsole/components/index.js
new file mode 100644
index 0000000..31ac5cd
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/components/index.js
@@ -0,0 +1,72 @@
+import './TwptConfirmDialog.js';
+import './TwptCRImportButton.js';
+import './TwptWorkflowDialog.js';
+import './TwptWorkflowsMenu.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import WorkflowsStorage from '../../workflowsStorage.js';
+
+export default class TwptWorkflowsInject extends LitElement {
+  static properties = {
+    _workflows: {type: Object},
+    _selectedWorkflowUuid: {type: String},
+  };
+
+  confirmDialogRef = createRef();
+  workflowDialogRef = createRef();
+
+  constructor() {
+    super();
+    this._workflows = null;
+    this._selectedWorkflowUuid = null;
+    this.addEventListener('twpt-workflows-update', e => {
+      const workflows = e.detail?.workflows ?? [];
+      WorkflowsStorage.convertRawListToProtobuf(workflows);
+      this._workflows = workflows;
+    });
+  }
+
+  render() {
+    return html`
+      <twpt-workflows-menu
+          .workflows=${this._workflows}
+          @select=${this._workflowSelected}>
+      </twpt-workflows-menu>
+      <twpt-confirm-dialog ${ref(this.confirmDialogRef)}
+          .workflow=${this._selectedWorkflow}
+          @confirm=${this._startWorkflow}>
+      </twpt-confirm-dialog>
+      <twpt-workflow-dialog ${ref(this.workflowDialogRef)}>
+      </twpt-workflow-dialog>
+    `;
+  }
+
+  _workflowSelected(e) {
+    const uuid = e.detail?.selectedWorkflowUuid;
+    if (!uuid) {
+      console.error('Didn\'t get a correct uuid for the selected workflow.');
+      return;
+    }
+    this._selectedWorkflowUuid = uuid;
+    this.confirmDialogRef.value.open = true;
+  }
+
+  _startWorkflow() {
+    this.workflowDialogRef.value.workflow =
+        this._selectedWorkflow.cloneMessage();
+    this.workflowDialogRef.value.start();
+  }
+
+  get _selectedWorkflow() {
+    if (!this._workflows) return null;
+
+    for (const w of this._workflows) {
+      if (w.uuid == this._selectedWorkflowUuid) return w.proto;
+    }
+
+    return null;
+  }
+}
+window.customElements.define('twpt-workflows-inject', TwptWorkflowsInject);
diff --git a/src/features/workflows/core/communityConsole/import.js b/src/features/workflows/core/communityConsole/import.js
new file mode 100644
index 0000000..68eb5a7
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/import.js
@@ -0,0 +1,108 @@
+import {waitFor} from 'poll-until-promise';
+
+import {recursiveParentElement} from '../../../../common/commonUtils.js';
+import {injectStylesheet} from '../../../../common/contentScriptsUtils.js';
+import {isOptionEnabled} from '../../../../common/optionsUtils.js';
+
+const kListCannedResponsesResponse = 'TWPT_ListCannedResponsesResponse';
+
+const kImportParam = 'TWPTImportToWorkflow';
+const kSelectedIdParam = 'TWPTSelectedId';
+
+// Class which is used to inject a "select" button in the CRs list when loading
+// the canned response list for this purpose from the workflows manager.
+export default class WorkflowsImport {
+  constructor() {
+    // Only set this class up if the Community Console was opened with the
+    // purpose of importing CRs to the workflow manager.
+    const searchParams = new URLSearchParams(document.location.search);
+    if (!searchParams.has(kImportParam)) return;
+
+    this.selectedId = searchParams.get(kSelectedIdParam);
+
+    this.lastCRsList = {
+      body: {},
+      id: -1,
+      duplicateNames: new Set(),
+    };
+
+    this.setUpHandler();
+    this.addCustomStyles();
+  }
+
+  setUpHandler() {
+    window.addEventListener(kListCannedResponsesResponse, e => {
+      if (e.detail.id < this.lastCRsList.id) return;
+
+      // Look if there are duplicate names
+      const crs = e.detail.body?.['1'] ?? [];
+      const names = crs.map(cr => cr?.['7']).slice().sort();
+      let duplicateNames = new Set();
+      for (let i = 1; i < names.length; i++)
+        if (names[i - 1] == names[i]) duplicateNames.add(names[i]);
+
+      this.lastCRsList = {
+        body: e.detail.body,
+        id: e.detail.id,
+        duplicateNames,
+      };
+    });
+  }
+
+  addCustomStyles() {
+    injectStylesheet(chrome.runtime.getURL('css/workflow_import.css'));
+  }
+
+  addButton(tags) {
+    const cr = recursiveParentElement(tags, 'EC-CANNED-RESPONSE-ROW');
+    if (!cr) return;
+
+    const name = cr.querySelector('.text .name').textContent;
+    if (!name) return;
+
+    const toolbar = cr.querySelector('.action .toolbar');
+    if (!toolbar) return console.error(`Can't find toolbar.`);
+
+    // If it has already been injected, exit.
+    if (toolbar.querySelector('twpt-cr-import-button')) return;
+
+    waitFor(() => {
+      if (this.lastCRsList.id != -1) return Promise.resolve(this.lastCRsList);
+      return Promise.reject(new Error('Didn\'t receive canned responses list'));
+    }, {
+      interval: 500,
+      timeout: 15 * 1000,
+    }).then(crs => {
+      // If another CR has the same name, there's no easy way to distinguish
+      // them, so don't inject the button.
+      if (crs.duplicateNames.has(name)) {
+        console.warning(
+            'CR "' + name +
+            '" is duplicate, so skipping the injection of the button.');
+        return;
+      }
+
+      for (const cr of (crs.body?.[1] ?? [])) {
+        if (cr[7] == name) {
+          const id = cr?.[1]?.[1];
+          if (!id) {
+            console.error('Can\'t find ID for canned response', cr);
+            break;
+          }
+
+          const button = document.createElement('twpt-cr-import-button');
+          button.setAttribute('cannedResponseId', id);
+          if (this.selectedId == id) button.setAttribute('selected', '');
+          toolbar.prepend(button);
+          break;
+        }
+      }
+    });
+  }
+
+  addButtonIfEnabled(tags) {
+    isOptionEnabled('workflows').then(isEnabled => {
+      if (isEnabled) this.addButton(tags);
+    });
+  }
+}
diff --git a/src/features/workflows/core/communityConsole/models/thread.js b/src/features/workflows/core/communityConsole/models/thread.js
new file mode 100644
index 0000000..2618bcc
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/models/thread.js
@@ -0,0 +1,117 @@
+import {waitFor} from 'poll-until-promise';
+
+import {CCApi} from '../../../../../common/api.js';
+import {parseUrl} from '../../../../../common/commonUtils.js';
+import {getAuthUser} from '../../../../../common/communityConsoleUtils.js';
+
+export default class Thread {
+  constructor(forumId, threadId) {
+    this.forumId = forumId;
+    this.threadId = threadId;
+    this._details = null;
+  }
+
+  static fromUrl(url) {
+    const rawThread = parseUrl(url);
+    if (!rawThread) return null;
+
+    return new Thread(rawThread.forum, rawThread.thread);
+  }
+
+  loadThreadDetails() {
+    if (this._details) return Promise.resolve(true);
+
+    return waitFor(
+               () => {
+                 return CCApi(
+                            'ViewForum', {
+                              1: '0',  // forumID,
+                              // options
+                              2: {
+                                3: false,   // withMessages
+                                5: true,    // withUserProfile
+                                6: false,   // withUserReadState
+                                7: false,   // withStickyThreads
+                                9: false,   // withRequestorProfile
+                                10: false,  // withPromotedMessages
+                                11: false,  // withExpertResponder
+                                12: `forum:${this.forumId} thread:${
+                                    this.threadId}`,  // forumViewFilters
+                                16: false,            // withThreadNotes
+                                17: false,  // withExpertReplyingIndicator
+                              },
+                            },
+                            /* authenticated = */ true, getAuthUser())
+                     .then(res => {
+                       if (res?.['1']?.['2']?.length < 1)
+                         throw new Error(
+                             `Couldn't retrieve thread details (forum: ${
+                                 this.forumId}, thread: ${this.thread}).`);
+
+                       return res?.['1']?.['2']?.[0];
+                     });
+               },
+               {
+                 interval: 500,
+                 timeout: 2000,
+               })
+        .then(thread => {
+          this._details = thread;
+          return true;
+        });
+  }
+
+  get opName() {
+    return this._details?.['4']?.['1']?.['1'];
+  }
+
+  get opUserId() {
+    return this._details?.['4']?.['3'];
+  }
+
+  get forumTitle() {
+    return this._details?.['23'];
+  }
+
+  get isRead() {
+    return !!this._details?.['6'];
+  }
+
+  get isStarred() {
+    return !!this._details?.['7']?.['1'];
+  }
+
+  get numMessages() {
+    return this._details?.['8'];
+  }
+
+  get numAnswers() {
+    return this._details?.['15'];
+  }
+
+  get numSuggestedAnswers() {
+    return this._details?.['32'];
+  }
+
+  get title() {
+    return this._details?.['2']?.['9'];
+  }
+
+  get lastMessageId() {
+    return this._details?.['2']?.['10'];
+  }
+
+  get payload() {
+    return this._details?.['2']?.['13'];
+  }
+
+  // Accessors in the style of
+  // https://support.google.com/communities/answer/9147001.
+  get op_name() {
+    return this.opName;
+  }
+
+  get forum_name() {
+    return this.forumTitle;
+  }
+}
diff --git a/src/features/workflows/core/communityConsole/runner.js b/src/features/workflows/core/communityConsole/runner.js
new file mode 100644
index 0000000..dfcb4d1
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/runner.js
@@ -0,0 +1,163 @@
+import {recursiveParentElement} from '../../../../common/commonUtils.js';
+import * as pb from '../proto/main_pb.js';
+
+import AttributeRunner from './actionRunners/attribute.js';
+import ReadStateRunner from './actionRunners/readState.js';
+import CRRunner from './actionRunners/replyWithCR.js';
+import Thread from './models/thread.js';
+
+export default class WorkflowRunner {
+  constructor(workflow, updateCallback) {
+    this.workflow = workflow;
+    this._threads = [];
+    this._currentThreadIndex = 0;
+    this._currentActionIndex = 0;
+    // Can be 'waiting', 'running', 'error', 'finished'
+    this._status = 'waiting';
+    this._updateCallback = updateCallback;
+
+    // Initialize action runners:
+    this._AttributeRunner = new AttributeRunner();
+    this._CRRunner = new CRRunner();
+    this._ReadStateRunner = new ReadStateRunner();
+  }
+
+  start() {
+    this._getSelectedThreads();
+    this.status = 'running';
+    this._runNextAction();
+  }
+
+  _getSelectedThreads() {
+    let threads = [];
+    const checkboxes = document.querySelectorAll(
+        '.thread-group material-checkbox[aria-checked="true"]');
+
+    for (const checkbox of checkboxes) {
+      const url = recursiveParentElement(checkbox, 'EC-THREAD-SUMMARY')
+                      .querySelector('a.header-content')
+                      .href;
+      const thread = Thread.fromUrl(url);
+      if (!thread) {
+        console.error('Couldn\'t parse URL ' + url);
+        continue;
+      }
+      threads.push(thread);
+    }
+
+    this.threads = threads;
+  }
+
+  _showError(err) {
+    console.warn(
+        `An error ocurred while executing action ${this.currentActionIndex}.`,
+        err);
+    this.status = 'error';
+  }
+
+  _runAction() {
+    switch (this._currentAction?.getActionCase?.()) {
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        return this._AttributeRunner.execute(
+            this._currentAction?.getAttributeAction?.(), this._currentThread);
+
+      case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+        return this._CRRunner.execute(
+            this._currentAction?.getReplyWithCrAction?.(), this._currentThread);
+
+      case pb.workflows.Action.ActionCase.MARK_AS_READ_ACTION:
+        return this._ReadStateRunner.execute(true, this._currentThread);
+
+      case pb.workflows.Action.ActionCase.MARK_AS_UNREAD_ACTION:
+        return this._ReadStateRunner.execute(false, this._currentThread);
+
+      default:
+        return Promise.reject(new Error('This action isn\'t supported yet.'));
+    }
+  }
+
+  _runNextAction() {
+    if (this.status !== 'running')
+      return console.error(
+          'Trying to run next action with status ' + this.status + '.');
+
+    this._runAction()
+        .then(() => {
+          if (this._nextActionIfAvailable())
+            this._runNextAction();
+          else
+            this._finish();
+        })
+        .catch(err => this._showError(err));
+  }
+
+  _nextActionIfAvailable() {
+    if (this.currentActionIndex === this._actions.length - 1) {
+      if (this.currentThreadIndex === this.numThreads - 1) return false;
+
+      this.currentThreadIndex++;
+      this.currentActionIndex = 0;
+      return true;
+    }
+
+    this.currentActionIndex++;
+    return true;
+  }
+
+  _finish() {
+    this.status = 'finished';
+  }
+
+  get numThreads() {
+    return this.threads.length ?? 0;
+  }
+
+  get _actions() {
+    return this.workflow?.getActionsList?.();
+  }
+
+  get _currentAction() {
+    return this._actions?.[this.currentActionIndex];
+  }
+
+  get _currentThread() {
+    return this._threads?.[this.currentThreadIndex];
+  }
+
+  // Setters/getters for properties, which will update the UI when changed.
+  get threads() {
+    return this._threads;
+  }
+
+  set threads(value) {
+    this._threads = value;
+    this._updateCallback();
+  }
+
+  get currentThreadIndex() {
+    return this._currentThreadIndex;
+  }
+
+  set currentThreadIndex(value) {
+    this._currentThreadIndex = value;
+    this._updateCallback();
+  }
+
+  get currentActionIndex() {
+    return this._currentActionIndex;
+  }
+
+  set currentActionIndex(value) {
+    this._currentActionIndex = value;
+    this._updateCallback();
+  }
+
+  get status() {
+    return this._status;
+  }
+
+  set status(value) {
+    this._status = value;
+    this._updateCallback();
+  }
+}
diff --git a/src/features/workflows/core/communityConsole/workflows.js b/src/features/workflows/core/communityConsole/workflows.js
new file mode 100644
index 0000000..652df29
--- /dev/null
+++ b/src/features/workflows/core/communityConsole/workflows.js
@@ -0,0 +1,50 @@
+import {isOptionEnabled} from '../../../../common/optionsUtils.js';
+import WorkflowsStorage from '../workflowsStorage.js';
+import {addElementToThreadListActions, shouldAddBtnToActionBar} from '../../../../contentScripts/communityConsole/utils/common.js';
+
+const wfDebugId = 'twpt-workflows';
+
+export default class Workflows {
+  constructor() {
+    this.menu = null;
+    this.workflows = null;
+
+    // Always keep the workflows list updated
+    WorkflowsStorage.watch(workflows => {
+      this.workflows = workflows;
+      this._emitWorkflowsUpdateEvent();
+    }, /* asProtobuf = */ false);
+
+    // Open the workflow manager when instructed to do so.
+    document.addEventListener('twpt-open-workflow-manager', () => {
+      chrome.runtime.sendMessage({
+        message: 'openWorkflowsManager',
+      });
+    });
+  }
+
+  _emitWorkflowsUpdateEvent() {
+    if (!this.menu) return;
+    const e = new CustomEvent('twpt-workflows-update', {
+      detail: {
+        workflows: this.workflows,
+      }
+    });
+    this.menu?.dispatchEvent?.(e);
+  }
+
+  addThreadListBtnIfEnabled(readToggle) {
+    isOptionEnabled('workflows').then(isEnabled => {
+      if (isEnabled) {
+        this.menu = document.createElement('twpt-workflows-inject');
+        this.menu.setAttribute('debugid', wfDebugId);
+        this._emitWorkflowsUpdateEvent();
+        addElementToThreadListActions(readToggle, this.menu);
+      }
+    });
+  }
+
+  shouldAddThreadListBtn(node) {
+    return shouldAddBtnToActionBar(wfDebugId, node);
+  }
+};
diff --git a/src/features/workflows/core/manager/components/ActionEditor.js b/src/features/workflows/core/manager/components/ActionEditor.js
new file mode 100644
index 0000000..cdcb85a
--- /dev/null
+++ b/src/features/workflows/core/manager/components/ActionEditor.js
@@ -0,0 +1,235 @@
+import './actions/Attribute.js';
+import './actions/ReplyWithCR.js';
+import '@material/mwc-circular-progress/mwc-circular-progress.js';
+
+import {html, LitElement, nothing} from 'lit';
+import {map} from 'lit/directives/map.js';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import * as pb from '../../proto/main_pb.js';
+import {kActionHeadings, kActionStyles, kSupportedActions} from '../shared/actions.js';
+
+const actionCases = Object.entries(pb.workflows.Action.ActionCase);
+
+export default class WFActionEditor extends LitElement {
+  static properties = {
+    action: {type: Object},
+    readOnly: {type: Boolean},
+    disableRemoveButton: {type: Boolean},
+    step: {type: Number},
+    status: {type: String},
+  };
+
+  static styles = kActionStyles;
+
+  selectRef = createRef();
+
+  constructor() {
+    super();
+    this.action = new pb.workflows.Action();
+    this.readOnly = false;
+  }
+
+  renderActionTitle() {
+    if (this.readOnly) return html`<h3 class="title">${this._stepTitle()}</h3>`;
+
+    let selectedActionCase = this._actionCase;
+
+    return html`
+      <select ${ref(this.selectRef)}
+          class="select"
+          @change=${this._actionCaseChanged}>
+        ${map(actionCases, ([actionName, num]) => {
+      if (!kSupportedActions.has(num)) return nothing;
+      return html`
+          <option value=${num} ?selected=${selectedActionCase == num}>
+            ${kActionHeadings[num] ?? actionName}
+          </option>
+        `;
+    })}
+      </select>
+    `;
+  }
+
+  renderSpecificActionEditor() {
+    switch (this._actionCase) {
+      case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+        return html`
+          <wf-action-reply-with-cr
+              ?readOnly=${this.readOnly}
+              .action=${this.action.getReplyWithCrAction()}>
+          </wf-action-reply-with-cr>
+        `;
+
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        return html`
+          <wf-action-attribute
+              ?readOnly=${this.readOnly}
+              .action=${this.action.getAttributeAction()}>
+          </wf-action-attribute>
+        `;
+
+      case pb.workflows.Action.ActionCase.MARK_AS_READ_ACTION:
+      case pb.workflows.Action.ActionCase.MARK_AS_UNREAD_ACTION:
+        return nothing;
+
+      default:
+        return html`<p>This action has not yet been implemented.</p>`;
+    }
+  }
+
+  render() {
+    let actionClass = '';
+    if (this.readOnly && this.status) actionClass = 'action--' + this.status;
+    return html`
+      <div class="action ${actionClass}">
+        <div class="header">
+          <div class="step">
+            ${this.step}
+            ${
+        this.status == 'running' ?
+            html`<mwc-circular-progress indeterminate density="-1"></mwc-circular-progress>` :
+            ''}
+          </div>
+          ${this.renderActionTitle()}
+          ${
+    !this.readOnly ? html`
+              <button
+                  ?disabled=${this.disableRemoveButton}
+                  @click=${this._remove}>
+                Remove
+              </button>
+            ` :
+                     nothing}
+        </div>
+        ${this.renderSpecificActionEditor()}
+      </div>
+    `;
+  }
+
+  checkValidity() {
+    if (this.readOnly || !kSupportedActions.has(this._actionCase)) return true;
+
+    const s = this._specificActionEditor();
+    if (!s) return true;
+
+    return this._specificActionEditor().checkValidity();
+  }
+
+  _actionCaseChanged() {
+    this._actionCaseString = this.selectRef.value.value;
+  }
+
+  _dispatchUpdateEvent() {
+    // Transmit to other components that the action has changed
+    const e = new Event('action-updated', {bubbles: true, composed: true});
+    this.renderRoot.dispatchEvent(e);
+  }
+
+  _remove() {
+    // Transmit to other components that the action has to be removed
+    const e = new Event('action-removed', {bubbles: true, composed: true});
+    this.renderRoot.dispatchEvent(e);
+  }
+
+  _stepTitle() {
+    return kActionHeadings[this._actionCase] ?? this._actionCase;
+  }
+
+  get _actionCase() {
+    return this.action.getActionCase();
+  }
+
+  set _actionCase(newCase) {
+    let value;
+    switch (newCase) {
+      case pb.workflows.Action.ActionCase.REPLY_ACTION:
+        value = new pb.workflows.Action.ReplyAction;
+        this.action.setReplyAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.MOVE_ACTION:
+        value = new pb.workflows.Action.MoveAction;
+        this.action.setMoveAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.MARK_DUPLICATE_ACTION:
+        value = new pb.workflows.Action.MarkDuplicateAction;
+        this.action.setMarkDuplicateAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.UNMARK_DUPLICATE_ACTION:
+        value = new pb.workflows.Action.UnmarkDuplicateAction;
+        this.action.setUnmarkDuplicateAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        value = new pb.workflows.Action.AttributeAction;
+        this.action.setAttributeAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+        value = new pb.workflows.Action.ReplyWithCRAction;
+        this.action.setReplyWithCrAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.STAR_ACTION:
+        value = new pb.workflows.Action.StarAction;
+        this.action.setStarAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.SUBSCRIBE_ACTION:
+        value = new pb.workflows.Action.SubscribeAction;
+        this.action.setSubscribeAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.VOTE_ACTION:
+        value = new pb.workflows.Action.VoteAction;
+        this.action.setVoteAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.REPORT_ACTION:
+        value = new pb.workflows.Action.ReportAction;
+        this.action.setReportAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.MARK_AS_READ_ACTION:
+        value = new pb.workflows.Action.MarkAsReadAction;
+        this.action.setMarkAsReadAction(value);
+        break;
+      case pb.workflows.Action.ActionCase.MARK_AS_UNREAD_ACTION:
+        value = new pb.workflows.Action.MarkAsUnreadAction;
+        this.action.setMarkAsUnreadAction(value);
+        break;
+      default:
+        this.action.clearReplyAction();
+        this.action.clearMoveAction();
+        this.action.clearMarkDuplicateAction();
+        this.action.clearUnmarkDuplicateAction();
+        this.action.clearAttributeAction();
+        this.action.clearReplyWithCrAction();
+        this.action.clearStarAction();
+        this.action.clearSubscribeAction();
+        this.action.clearVoteAction();
+        this.action.clearReportAction();
+        this.action.clearMarkAsReadAction();
+        this.action.clearMarkAsUnreadAction();
+    }
+
+    this.requestUpdate();
+    this._dispatchUpdateEvent();
+  }
+
+  // The same as _actionCase, but represented as a String instead of a Number
+  get _actionCaseString() {
+    return this._actionCase.toString();
+  }
+
+  set _actionCaseString(newCase) {
+    this._actionCase = parseInt(newCase);
+  }
+
+  _specificActionEditor() {
+    switch (this._actionCase) {
+      case pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION:
+        return this.renderRoot.querySelector('wf-action-reply-with-cr');
+
+      case pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION:
+        return this.renderRoot.querySelector('wf-action-attribute');
+
+      default:
+        return null;
+    }
+  }
+}
+window.customElements.define('wf-action-editor', WFActionEditor);
diff --git a/src/features/workflows/core/manager/components/AddDialog.js b/src/features/workflows/core/manager/components/AddDialog.js
new file mode 100644
index 0000000..bcd582c
--- /dev/null
+++ b/src/features/workflows/core/manager/components/AddDialog.js
@@ -0,0 +1,93 @@
+import '@material/mwc-dialog/mwc-dialog.js';
+import '@material/web/button/text-button.js';
+import '@material/web/button/filled-button.js';
+import './WorkflowEditor.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import * as pb from '../../proto/main_pb.js';
+
+export default class WFAddDialog extends LitElement {
+  static properties = {
+    open: {type: Boolean},
+  };
+
+  static styles = css`
+    :host {
+      --mdc-dialog-content-ink-color: var(--mdc-theme-on-surface, #000);
+    }
+  `;
+
+  workflowEditorRef = createRef();
+
+  constructor() {
+    super();
+    this.open = false;
+  }
+
+  render() {
+    return html`
+      <mwc-dialog
+          ?open=${this.open}
+          @opening=${this._openingDialog}
+          @closing=${this._closingDialog}
+          @closed=${this._closedDialog}>
+        <wf-workflow-editor ${ref(this.workflowEditorRef)}>
+        </wf-workflow-editor>
+        <md-filled-button
+            slot="primaryAction"
+            @click=${this._save}>
+          Add
+        </md-filled-button>
+        <md-text-button
+            slot="secondaryAction"
+            dialogAction="cancel">
+          Cancel
+        </md-text-button>
+      </mwc-dialog>
+    `;
+  }
+
+  firstUpdated() {
+    this._resetWorkflow();
+  }
+
+  _resetWorkflow() {
+    this.workflowEditorRef.value.workflow = this._defaultWorkflow();
+  }
+
+  _getWorkflow() {
+    return this.workflowEditorRef.value.workflow;
+  }
+
+  _defaultWorkflow() {
+    let wf = new pb.workflows.Workflow();
+    let action = new pb.workflows.Action();
+    let rAction = new pb.workflows.Action.ReplyWithCRAction();
+    action.setReplyWithCrAction(rAction);
+    wf.addActions(action);
+    return wf;
+  }
+
+  _openingDialog() {
+    this.open = true;
+  }
+
+  _closingDialog() {
+    this.open = false;
+  }
+
+  _closedDialog(e) {
+    if (e.detail?.action === 'cancel') this._resetWorkflow();
+  }
+
+  _save() {
+    const success = this.workflowEditorRef.value.save();
+    if (success) {
+      this.open = false;
+      this._resetWorkflow();
+    }
+  }
+}
+window.customElements.define('wf-add-dialog', WFAddDialog);
diff --git a/src/features/workflows/core/manager/components/List.js b/src/features/workflows/core/manager/components/List.js
new file mode 100644
index 0000000..9f7ed5f
--- /dev/null
+++ b/src/features/workflows/core/manager/components/List.js
@@ -0,0 +1,97 @@
+import '@material/web/list/list.js';
+import '@material/web/list/list-item.js';
+import '@material/web/icon/icon.js';
+import '@material/web/iconbutton/icon-button.js';
+import './WorkflowDialog.js';
+
+import {css, html, LitElement, nothing} from 'lit';
+import {map} from 'lit/directives/map.js';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import WorkflowsStorage from '../../workflowsStorage.js';
+
+export default class WFList extends LitElement {
+  static properties = {
+    workflows: {type: Object},
+  };
+
+  static styles = css`
+    .noworkflows {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      margin: 32px 0;
+    }
+
+    .noworkflows--image {
+      margin-bottom: 16px;
+      max-width: 500px;
+    }
+
+    .noworkflows--helper {
+      color: #555;
+    }
+  `;
+
+  dialogRef = createRef();
+
+  renderListItems() {
+    return map(this.workflows, w => html`
+      <md-list-item
+          type="button"
+          @click=${() => this._show(w)}>
+        <div slot="headline">${w.proto?.getName?.()}</div>
+        <div slot="end" class="end">
+          <md-icon-button
+              @click=${e => this._showDelete(w.uuid, e)}>
+            <md-icon>delete</md-icon>
+          </md-icon-button>
+        </div>
+      </md-list-item>
+    `);
+  }
+
+  renderList() {
+    if (!this.workflows) return nothing;
+    if (this.workflows?.length === 0)
+      return html`
+      <div class="noworkflows">
+        <img class="noworkflows--image" src="/img/undraw_insert.svg">
+        <span class="noworkflows--helper">You haven't created any workflow yet! Create one by clicking the button in the bottom-right corner.</span>
+      </div>
+    `;
+
+    return html`
+      <md-list>
+        ${this.renderListItems()}
+      </md-list>
+    `;
+  }
+
+  renderDialog() {
+    return html`
+      <wf-workflow-dialog ${ref(this.dialogRef)}></wf-workflow-dialog>
+    `;
+  }
+
+  render() {
+    return [
+      this.renderList(),
+      this.renderDialog(),
+    ];
+  }
+
+  _show(fullWorkflow) {
+    this.dialogRef.value.uuid = fullWorkflow.uuid;
+    this.dialogRef.value.workflow = fullWorkflow.proto.cloneMessage();
+    this.dialogRef.value.open = true;
+  }
+
+  _showDelete(uuid, e) {
+    e.stopPropagation();
+    const proceed = window.confirm(
+        'Do you really want to remove this workflow? This action is irreversible.');
+    if (proceed) WorkflowsStorage.remove(uuid);
+  }
+}
+window.customElements.define('wf-list', WFList);
diff --git a/src/features/workflows/core/manager/components/WorkflowDialog.js b/src/features/workflows/core/manager/components/WorkflowDialog.js
new file mode 100644
index 0000000..e990246
--- /dev/null
+++ b/src/features/workflows/core/manager/components/WorkflowDialog.js
@@ -0,0 +1,68 @@
+import '@material/mwc-dialog/mwc-dialog.js';
+import '@material/web/button/text-button.js';
+import '@material/web/button/filled-button.js';
+import './WorkflowEditor.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import * as pb from '../../proto/main_pb.js';
+
+export default class WFWorkflowDialog extends LitElement {
+  static properties = {
+    open: {type: Boolean},
+    uuid: {type: String},
+    workflow: {type: Object},
+  };
+
+  static styles = css`
+    :host {
+      --mdc-dialog-content-ink-color: var(--mdc-theme-on-surface, #000);
+    }
+  `;
+
+  workflowEditorRef = createRef();
+
+  constructor() {
+    super();
+    this.open = false;
+    this.workflow = new pb.workflows.Workflow();
+  }
+
+  render() {
+    return html`
+      <mwc-dialog
+          ?open=${this.open}
+          @opening=${this._openingDialog}
+          @closing=${this._closingDialog}>
+        <wf-workflow-editor ${ref(this.workflowEditorRef)}
+            .workflow=${this.workflow}>
+        </wf-workflow-editor>
+        <md-filled-button
+            slot="primaryAction"
+            @click=${this._save}>
+          Save
+        </md-filled-button>
+        <md-text-button
+            slot="secondaryAction"
+            dialogAction="cancel">
+          Cancel
+        </md-text-button>
+      </mwc-dialog>
+    `;
+  }
+
+  _openingDialog() {
+    this.open = true;
+  }
+
+  _closingDialog() {
+    this.open = false;
+  }
+
+  _save() {
+    const success = this.workflowEditorRef.value.save(this.uuid);
+    if (success) this.open = false;
+  }
+}
+window.customElements.define('wf-workflow-dialog', WFWorkflowDialog);
diff --git a/src/features/workflows/core/manager/components/WorkflowEditor.js b/src/features/workflows/core/manager/components/WorkflowEditor.js
new file mode 100644
index 0000000..1818ea7
--- /dev/null
+++ b/src/features/workflows/core/manager/components/WorkflowEditor.js
@@ -0,0 +1,144 @@
+import '@material/web/button/outlined-button.js';
+import '@material/web/icon/icon.js';
+import '@material/web/textfield/filled-text-field.js';
+import './ActionEditor.js';
+
+import {css, html, LitElement, nothing} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+import {repeat} from 'lit/directives/repeat.js';
+
+import * as pb from '../../proto/main_pb.js';
+import WorkflowsStorage from '../../workflowsStorage.js';
+
+export default class WFWorkflowEditor extends LitElement {
+  static properties = {
+    workflow: {type: Object},
+    readOnly: {type: Boolean},
+  };
+
+  static styles = css`
+    .name {
+      width: 100%;
+      margin-bottom: 20px;
+    }
+  `;
+
+  nameRef = createRef();
+
+  constructor() {
+    super();
+    this.workflow = new pb.workflows.Workflow();
+    this.readOnly = false;
+  }
+
+  renderName() {
+    return html`
+      <md-filled-text-field ${ref(this.nameRef)}
+          class="name"
+          placeholder="Untitled workflow"
+          value=${this.workflow.getName()}
+          required
+          @input=${this._nameChanged}>
+      </md-filled-text-field>
+    `;
+  }
+
+  renderActions() {
+    return repeat(this._actions(), (action, i) => html`
+      <wf-action-editor
+          .action=${action}
+          ?readOnly=${this.readOnly}
+          ?disableRemoveButton=${this._actions().length <= 1}
+          step=${i + 1}
+          @action-removed=${() => this._removeAction(i)}>
+      </wf-action-editor>
+    `);
+  }
+
+  renderAddActionBtn() {
+    if (this.readOnly) return nothing;
+    return html`
+      <md-outlined-button
+          @click=${this._addAction}>
+        <md-icon slot="icon">add</md-icon>
+        Add another action
+      </md-outlined-button>
+    `;
+  }
+
+  render() {
+    return [
+      this.renderName(),
+      this.renderActions(),
+      this.renderAddActionBtn(),
+    ];
+  }
+
+  checkValidity() {
+    let allValid = true;
+
+    // Check the workflow name is set
+    allValid &&= this.nameRef.value.reportValidity();
+
+    // Check all the actions are well-formed
+    const actionEditors = this.renderRoot.querySelectorAll('wf-action-editor');
+    for (const editor of actionEditors) allValid &&= editor.checkValidity();
+
+    return allValid;
+  }
+
+  save(uuid) {
+    const allValid = this.checkValidity();
+
+    // Save the workflow if the validation checks passed
+    if (allValid) {
+      if (!uuid)
+        WorkflowsStorage.add(this.workflow);
+      else
+        WorkflowsStorage.update(uuid, this.workflow);
+    }
+
+    return allValid;
+  }
+
+  _actions() {
+    return this.workflow.getActionsList();
+  }
+
+  _nameChanged() {
+    this.workflow.setName(this.nameRef.value.value);
+    this._dispatchUpdateEvent();
+  }
+
+  _addAction() {
+    let action = new pb.workflows.Action();
+    let rAction = new pb.workflows.Action.ReplyWithCRAction();
+    action.setReplyWithCrAction(rAction);
+    this.workflow.addActions(action);
+    this._dispatchUpdateEvent();
+  }
+
+  _removeAction(index) {
+    let actions = this.workflow.getActionsList();
+    actions.splice(index, 1);
+    this.workflow.setActionsList(actions);
+    this._dispatchUpdateEvent();
+  }
+
+  _updateAction(index, action) {
+    let actions = this.workflow.getActionsList();
+    actions[index] = action;
+    this.workflow.setActionsList(actions);
+    this._dispatchUpdateEvent();
+  }
+
+  _dispatchUpdateEvent() {
+    // Request an update for this component
+    this.requestUpdate();
+
+    // Transmit to other components that the workflow has changed
+    const e = new Event('workflow-updated', {bubbles: true, composed: true});
+    this.renderRoot.dispatchEvent(e);
+  }
+}
+window.customElements.define('wf-workflow-editor', WFWorkflowEditor);
diff --git a/src/features/workflows/core/manager/components/actions/Attribute.js b/src/features/workflows/core/manager/components/actions/Attribute.js
new file mode 100644
index 0000000..8076026
--- /dev/null
+++ b/src/features/workflows/core/manager/components/actions/Attribute.js
@@ -0,0 +1,87 @@
+import '@material/web/select/outlined-select.js';
+import '@material/web/select/select-option.js';
+
+import {html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../../../common/styles/md3.js';
+import * as pb from '../../../proto/main_pb.js';
+
+import {FORM_STYLES} from './common.js';
+
+const kHiddenActions = [
+  pb.workflows.Action.AttributeAction.AttributeAction.AA_NONE,
+];
+
+export default class WFActionAttribute extends LitElement {
+  static properties = {
+    action: {type: Object},
+    readOnly: {type: Boolean},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    FORM_STYLES,
+  ];
+
+  attributeActionRef = createRef();
+
+  constructor() {
+    super();
+    this.action = new pb.workflows.Action.AttributeAction;
+  }
+
+  render() {
+    return html`
+      <div class="form-line">
+        <md-outlined-select ${ref(this.attributeActionRef)}
+            required
+            label="Action"
+            value=${this.action}
+            ?disabled=${this.readOnly}
+            @change=${this._attributeActionChanged}>
+          ${this.renderAttributeActions()}
+        </md-outlined-select>
+      </div>
+    `;
+  }
+
+  renderAttributeActions() {
+    const attributeActions =
+        Object.entries(pb.workflows.Action.AttributeAction.AttributeAction);
+    return attributeActions.filter(([, id]) => !kHiddenActions.includes(id))
+        .map(([actionCodename, id]) => html`
+      <md-select-option value=${id}>
+        <div slot="headline">${actionCodename}</div>
+      </md-select-option>
+    `);
+  }
+
+  checkValidity() {
+    return this.attributeActionRef.value.reportValidity();
+  }
+
+  _dispatchUpdateEvent() {
+    // Request an update for this component
+    this.requestUpdate();
+
+    // Transmit to other components that the action has changed
+    const e =
+        new Event('attribute-action-updated', {bubbles: true, composed: true});
+    this.renderRoot.dispatchEvent(e);
+  }
+
+  _attributeActionChanged() {
+    this.attributeAction = this.attributeActionRef.value.value;
+  }
+
+  get attributeAction() {
+    return this.action.getAttributeAction();
+  }
+
+  set attributeAction(value) {
+    this.action.setAttributeAction(value);
+    this._dispatchUpdateEvent();
+  }
+}
+window.customElements.define('wf-action-attribute', WFActionAttribute);
diff --git a/src/features/workflows/core/manager/components/actions/ReplyWithCR.js b/src/features/workflows/core/manager/components/actions/ReplyWithCR.js
new file mode 100644
index 0000000..65d04e2
--- /dev/null
+++ b/src/features/workflows/core/manager/components/actions/ReplyWithCR.js
@@ -0,0 +1,173 @@
+import '@material/web/icon/icon.js';
+import '@material/web/switch/switch.js';
+import '@material/web/textfield/outlined-text-field.js';
+import '../../../../../../common/components/FormField.js';
+
+import {css, html, LitElement, nothing} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../../../common/styles/md3.js';
+import * as pb from '../../../proto/main_pb.js';
+import { FORM_STYLES } from './common.js';
+
+export default class WFActionReplyWithCR extends LitElement {
+  static properties = {
+    action: {type: Object},
+    readOnly: {type: Boolean},
+    _importerWindow: {type: Object, state: true},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    FORM_STYLES,
+    css`
+      .select-cr-btn {
+        --md-outlined-button-icon-size: 24px;
+      }
+    `,
+  ];
+
+  cannedResponseRef = createRef();
+  subscribeRef = createRef();
+  markAsAnswerRef = createRef();
+
+  constructor() {
+    super();
+    this.action = new pb.workflows.Action.ReplyWithCRAction;
+    this._importerWindow = undefined;
+
+    window.addEventListener('message', e => {
+      if (e.source === this._importerWindow &&
+          e.data?.action === 'importCannedResponse') {
+        this._cannedResponseIdString = e.data?.cannedResponseId;
+        this._importerWindow?.close?.();
+      }
+    });
+  }
+
+  render() {
+    return html`
+      <div class="form-line">
+        <md-outlined-text-field ${ref(this.cannedResponseRef)}
+            type="number"
+            label="Canned response ID"
+            required
+            value=${this._cannedResponseIdString}
+            ?readonly=${this.readOnly}
+            @input=${this._cannedResponseIdChanged}>
+        </md-outlined-text-field>
+        ${this.readOnly ? nothing : html`
+          <md-outlined-button
+              class="select-cr-btn"
+              @click=${this._openCRImporter}>
+            <md-icon slot="icon" filled>more</md-icon>
+            Select CR
+          </md-outlined-button>
+        `}
+      </div>
+      <div class="form-line">
+        <twpt-form-field>
+          <md-switch ${ref(this.subscribeRef)}
+              ?selected=${this.subscribe}
+              ?disabled=${this.readOnly}
+              @change=${this._subscribeChanged}/>
+          </md-switch>
+          <span slot="label">
+            Subscribe to thread
+          </span>
+        </twpt-form-field>
+      </div>
+      <div class="form-line">
+        <twpt-form-field>
+          <md-switch ${ref(this.markAsAnswerRef)}
+              ?selected=${this.markAsAnswer}
+              ?disabled=${this.readOnly}
+              @change=${this._markAsAnswerChanged}/>
+          </md-switch>
+          <span slot="label">
+            Mark as answer
+          </span>
+        </twpt-form-field>
+      </div>
+    `;
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    this._importerWindow?.close?.();
+  }
+
+  checkValidity() {
+    return this.cannedResponseRef.value.reportValidity();
+  }
+
+  _dispatchUpdateEvent() {
+    // Request an update for this component
+    this.requestUpdate();
+
+    // Transmit to other components that the action has changed
+    const e = new Event(
+        'replywithcr-action-updated', {bubbles: true, composed: true});
+    this.renderRoot.dispatchEvent(e);
+  }
+
+  _cannedResponseIdChanged() {
+    this._cannedResponseIdString = this.cannedResponseRef.value.value;
+  }
+
+  _subscribeChanged() {
+    this.subscribe = this.subscribeRef.value.selected;
+  }
+
+  _markAsAnswerChanged() {
+    this.markAsAnswer = this.markAsAnswerRef.value.selected;
+  }
+
+  _openCRImporter() {
+    if (!(this._importerWindow?.closed ?? true))
+      this._importerWindow?.close?.();
+
+    this._importerWindow = window.open(
+        'https://support.google.com/s/community/cannedresponses?TWPTImportToWorkflow&TWPTSelectedId=' +
+            encodeURIComponent(this._cannedResponseIdString),
+        '', 'popup,width=720,height=540');
+  }
+
+  get cannedResponseId() {
+    return this.action.getCannedResponseId() ?? 0;
+  }
+
+  set cannedResponseId(value) {
+    this.action.setCannedResponseId(value);
+    this._dispatchUpdateEvent();
+  }
+
+  get _cannedResponseIdString() {
+    let id = this.cannedResponseId;
+    if (id == 0) return '';
+    return id.toString();
+  }
+
+  set _cannedResponseIdString(value) {
+    this.cannedResponseId = parseInt(value);
+  }
+
+  get subscribe() {
+    return this.action.getSubscribe();
+  }
+
+  set subscribe(value) {
+    this.action.setSubscribe(value);
+    this._dispatchUpdateEvent();
+  }
+
+  get markAsAnswer() {
+    return this.action.getMarkAsAnswer();
+  }
+
+  set markAsAnswer(value) {
+    this.action.setMarkAsAnswer(value);
+    this._dispatchUpdateEvent();
+  }
+}
+window.customElements.define('wf-action-reply-with-cr', WFActionReplyWithCR);
diff --git a/src/features/workflows/core/manager/components/actions/common.js b/src/features/workflows/core/manager/components/actions/common.js
new file mode 100644
index 0000000..3d5be56
--- /dev/null
+++ b/src/features/workflows/core/manager/components/actions/common.js
@@ -0,0 +1,11 @@
+import {css} from 'lit';
+
+export const FORM_STYLES = css`
+  .form-line {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    margin-block: 1em;
+    gap: .5rem;
+  }
+`;
diff --git a/src/features/workflows/core/manager/index.js b/src/features/workflows/core/manager/index.js
new file mode 100644
index 0000000..68bbf21
--- /dev/null
+++ b/src/features/workflows/core/manager/index.js
@@ -0,0 +1,66 @@
+import '@material/web/fab/fab.js';
+import '@material/web/icon/icon.js';
+import './components/List.js';
+import './components/AddDialog.js';
+import './components/WorkflowDialog.js';
+
+import {css, html, LitElement} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+import {default as WorkflowsStorage, kWorkflowsDataKey} from '../workflowsStorage.js';
+
+export default class WFApp extends LitElement {
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+      :host {
+        font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI',
+            Helvetica, Arial, sans-serif, 'Apple Color Emoji',
+            'Segoe UI Emoji', 'Segoe UI Symbol'!important;
+
+        display: block;
+        max-width: 1024px;
+        margin: auto;
+        position: relative;
+      }
+
+      md-fab {
+        position: fixed;
+        bottom: 2em;
+        right: 2em;
+      }
+    `,
+  ];
+
+  addFabRef = createRef();
+  addDialog = createRef();
+
+  constructor() {
+    super();
+    this._workflows = undefined;
+    WorkflowsStorage.watch(workflows => {
+      this._workflows = workflows;
+      this.requestUpdate();
+    }, /* asProtobuf = */ true);
+  }
+
+  render() {
+    return html`
+      <h1>Workflows</h1>
+      <p>Workflows allow you to run a customized list of actions on a thread easily.</p>
+      <wf-list .workflows=${this._workflows}></wf-list>
+      <md-fab ${ref(this.addFabRef)}
+          @click=${this._showAddDialog}>
+        <md-icon slot="icon">add</md-icon>
+      </md-fab>
+      <wf-add-dialog ${ref(this.addDialog)}>
+      </wf-add-dialog>
+    `;
+  }
+
+  _showAddDialog() {
+    this.addDialog.value.open = true;
+  }
+}
+window.customElements.define('wf-app', WFApp);
diff --git a/src/features/workflows/core/manager/shared/actions.js b/src/features/workflows/core/manager/shared/actions.js
new file mode 100644
index 0000000..ef23926
--- /dev/null
+++ b/src/features/workflows/core/manager/shared/actions.js
@@ -0,0 +1,85 @@
+import {css} from 'lit';
+
+import * as pb from '../../proto/main_pb.js';
+
+// TODO: remove this and substitute it with proper localization.
+export const kActionHeadings = {
+  0: 'Unknown action',
+  1: 'Reply',
+  2: 'Move to a forum',
+  3: 'Mark as duplicate of a thread',
+  4: 'Unmark duplicate',
+  5: 'Change thread attribute',
+  6: 'Reply with canned response',
+  16: 'Star/unstar thread',
+  17: 'Subscribe/unsubscribe to thread',
+  18: 'Vote thread',
+  19: 'Report thread',
+  20: 'Mark as read',
+  21: 'Mark as unread',
+};
+
+export const kSupportedActions = new Set([
+  pb.workflows.Action.ActionCase.ATTRIBUTE_ACTION,
+  pb.workflows.Action.ActionCase.REPLY_WITH_CR_ACTION,
+  pb.workflows.Action.ActionCase.MARK_AS_READ_ACTION,
+  pb.workflows.Action.ActionCase.MARK_AS_UNREAD_ACTION,
+]);
+
+export const kActionStyles = css`
+  .action {
+    margin-bottom: 20px;
+  }
+
+  .header {
+    display: flex;
+    align-items: center;
+    margin-bottom: 8px;
+  }
+
+  .step {
+    display: flex;
+    position: relative;
+    align-items: center;
+    justify-content: center;
+
+    min-height: 30px;
+    min-width: 30px;
+    margin-inline-end: 8px;
+
+    font-weight: 500;
+    font-size: 18px;
+
+    border-radius: 50%;
+    color: white;
+    background-color: #018786;
+  }
+
+  :is(.action--idle, .action--running) .step {
+    color: black;
+    background-color: #d1d1d1;
+  }
+
+  .action--error .step {
+    background-color: #c30000;
+  }
+
+  .step mwc-circular-progress {
+    position: absolute;
+    --mdc-theme-primary: #018786;
+  }
+
+  .title {
+    font-weight: 500;
+    margin: 0;
+    flex-grow: 1;
+  }
+
+  .header .select {
+    flex-grow: 1;
+    width: 300px;
+    padding: 4px;
+    margin-inline-end: 8px;
+    font-size: 16px;
+  }
+`;
diff --git a/src/features/workflows/core/proto/main.proto b/src/features/workflows/core/proto/main.proto
new file mode 100644
index 0000000..dd10682
--- /dev/null
+++ b/src/features/workflows/core/proto/main.proto
@@ -0,0 +1,110 @@
+syntax = "proto3";
+
+package workflows;
+
+message Thread {
+  int64 forum_id = 1;
+  int64 thread_id = 2;
+}
+
+message Action {
+  message ReplyAction {
+    string payload = 1;
+    bool subscribe = 2;
+    bool mark_as_answer = 3;
+  }
+
+  message MoveAction {
+    int64 forum_id = 1;
+    string category = 2;
+    string language = 3;
+    map<string, string> property = 4;
+  }
+
+  message MarkDuplicateAction {
+    Thread destination = 1;
+  }
+
+  message UnmarkDuplicateAction {}
+
+  message ReplyWithCRAction {
+    int64 canned_response_id = 1;
+    bool subscribe = 2;
+    bool mark_as_answer = 3;
+  }
+
+  message StarAction {
+    bool star = 1; // true stars, and false unstars.
+  }
+
+  message SubscribeAction {
+    bool subscribe = 1; // true subscribes, false unsubscribes.
+  }
+
+  message VoteAction {
+    enum Vote {
+      NONE = 0;
+      UP = 1;
+      DOWN = -1;
+    }
+    Vote vote = 1;
+  }
+
+  message AttributeAction {
+    enum AttributeAction {
+      AA_NONE = 0;
+      AA_LOCK = 1;
+      AA_UNLOCK = 2;
+      AA_PIN = 3;
+      AA_UNPIN = 4;
+      AA_NON_ISSUE = 5;
+      AA_OBSOLETE = 6;
+      AA_REVERT = 7;
+      AA_SET_TRENDING = 8;
+      AA_UNSET_TRENDING = 9;
+      AA_SET_ISSUE_RESOLVED = 10;
+      AA_UNSET_ISSUE_RESOLVED = 11;
+      AA_SOFT_LOCK = 12;
+      AA_UNSOFT_LOCK = 13;
+      AA_EXCLUDE_FROM_GOLDEN = 14;
+      AA_UNEXCLUDE_FROM_GOLDEN = 15;
+      AA_INCLUDE_IN_GOLDEN = 16;
+    }
+    AttributeAction attribute_action = 1;
+  }
+
+  message ReportAction {
+    enum ReportType {
+      RT_UNKNOWN = 0;
+      RT_OFF_TOPIC = 1;
+      RT_ABUSE = 2;
+    }
+    ReportType report_type = 1;
+  }
+
+  message MarkAsReadAction {}
+
+  message MarkAsUnreadAction {}
+
+  oneof action {
+    ReplyAction reply_action = 1;
+    MoveAction move_action = 2;
+    MarkDuplicateAction mark_duplicate_action = 3;
+    UnmarkDuplicateAction unmark_duplicate_action = 4;
+    AttributeAction attribute_action = 5;
+    ReplyWithCRAction reply_with_cr_action = 6;
+    StarAction star_action = 16;
+    SubscribeAction subscribe_action = 17;
+    VoteAction vote_action = 18;
+    ReportAction report_action = 19;
+    MarkAsReadAction mark_as_read_action = 20;
+    MarkAsUnreadAction mark_as_unread_action = 21;
+  }
+}
+
+message Workflow {
+  string name = 1;
+  string description = 2;
+  int32 index = 3;
+  repeated Action actions = 4;
+}
diff --git a/src/features/workflows/core/proto/main_pb.js b/src/features/workflows/core/proto/main_pb.js
new file mode 100644
index 0000000..75ee13b
--- /dev/null
+++ b/src/features/workflows/core/proto/main_pb.js
@@ -0,0 +1,3259 @@
+// source: proto/main.proto
+/**
+ * @fileoverview
+ * @enhanceable
+ * @suppress {missingRequire} reports error on implicit type usages.
+ * @suppress {messageConventions} JS Compiler reports an error if a variable or
+ *     field starts with 'MSG_' and isn't a translatable message.
+ * @public
+ */
+// GENERATED CODE -- DO NOT EDIT!
+/* eslint-disable */
+// @ts-nocheck
+
+var jspb = require('google-protobuf');
+var goog = jspb;
+var proto = {};
+
+goog.exportSymbol('workflows.Action', null, proto);
+goog.exportSymbol('workflows.Action.ActionCase', null, proto);
+goog.exportSymbol('workflows.Action.AttributeAction', null, proto);
+goog.exportSymbol('workflows.Action.AttributeAction.AttributeAction', null, proto);
+goog.exportSymbol('workflows.Action.MarkAsReadAction', null, proto);
+goog.exportSymbol('workflows.Action.MarkAsUnreadAction', null, proto);
+goog.exportSymbol('workflows.Action.MarkDuplicateAction', null, proto);
+goog.exportSymbol('workflows.Action.MoveAction', null, proto);
+goog.exportSymbol('workflows.Action.ReplyAction', null, proto);
+goog.exportSymbol('workflows.Action.ReplyWithCRAction', null, proto);
+goog.exportSymbol('workflows.Action.ReportAction', null, proto);
+goog.exportSymbol('workflows.Action.ReportAction.ReportType', null, proto);
+goog.exportSymbol('workflows.Action.StarAction', null, proto);
+goog.exportSymbol('workflows.Action.SubscribeAction', null, proto);
+goog.exportSymbol('workflows.Action.UnmarkDuplicateAction', null, proto);
+goog.exportSymbol('workflows.Action.VoteAction', null, proto);
+goog.exportSymbol('workflows.Action.VoteAction.Vote', null, proto);
+goog.exportSymbol('workflows.Thread', null, proto);
+goog.exportSymbol('workflows.Workflow', null, proto);
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Thread = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Thread, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Thread.displayName = 'proto.workflows.Thread';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, proto.workflows.Action.oneofGroups_);
+};
+goog.inherits(proto.workflows.Action, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.displayName = 'proto.workflows.Action';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.ReplyAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.ReplyAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.ReplyAction.displayName = 'proto.workflows.Action.ReplyAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.MoveAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.MoveAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.MoveAction.displayName = 'proto.workflows.Action.MoveAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.MarkDuplicateAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.MarkDuplicateAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.MarkDuplicateAction.displayName = 'proto.workflows.Action.MarkDuplicateAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.UnmarkDuplicateAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.UnmarkDuplicateAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.UnmarkDuplicateAction.displayName = 'proto.workflows.Action.UnmarkDuplicateAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.ReplyWithCRAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.ReplyWithCRAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.ReplyWithCRAction.displayName = 'proto.workflows.Action.ReplyWithCRAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.StarAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.StarAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.StarAction.displayName = 'proto.workflows.Action.StarAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.SubscribeAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.SubscribeAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.SubscribeAction.displayName = 'proto.workflows.Action.SubscribeAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.VoteAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.VoteAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.VoteAction.displayName = 'proto.workflows.Action.VoteAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.AttributeAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.AttributeAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.AttributeAction.displayName = 'proto.workflows.Action.AttributeAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.ReportAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.ReportAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.ReportAction.displayName = 'proto.workflows.Action.ReportAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.MarkAsReadAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.MarkAsReadAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.MarkAsReadAction.displayName = 'proto.workflows.Action.MarkAsReadAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Action.MarkAsUnreadAction = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
+};
+goog.inherits(proto.workflows.Action.MarkAsUnreadAction, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Action.MarkAsUnreadAction.displayName = 'proto.workflows.Action.MarkAsUnreadAction';
+}
+/**
+ * Generated by JsPbCodeGenerator.
+ * @param {Array=} opt_data Optional initial data array, typically from a
+ * server response, or constructed directly in Javascript. The array is used
+ * in place and becomes part of the constructed object. It is not cloned.
+ * If no data is provided, the constructed object will be empty, but still
+ * valid.
+ * @extends {jspb.Message}
+ * @constructor
+ */
+proto.workflows.Workflow = function(opt_data) {
+  jspb.Message.initialize(this, opt_data, 0, -1, proto.workflows.Workflow.repeatedFields_, null);
+};
+goog.inherits(proto.workflows.Workflow, jspb.Message);
+if (goog.DEBUG && !COMPILED) {
+  /**
+   * @public
+   * @override
+   */
+  proto.workflows.Workflow.displayName = 'proto.workflows.Workflow';
+}
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Thread.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Thread.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Thread} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Thread.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    forumId: jspb.Message.getFieldWithDefault(msg, 1, 0),
+    threadId: jspb.Message.getFieldWithDefault(msg, 2, 0)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Thread}
+ */
+proto.workflows.Thread.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Thread;
+  return proto.workflows.Thread.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Thread} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Thread}
+ */
+proto.workflows.Thread.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {number} */ (reader.readInt64());
+      msg.setForumId(value);
+      break;
+    case 2:
+      var value = /** @type {number} */ (reader.readInt64());
+      msg.setThreadId(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Thread.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Thread.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Thread} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Thread.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getForumId();
+  if (f !== 0) {
+    writer.writeInt64(
+      1,
+      f
+    );
+  }
+  f = message.getThreadId();
+  if (f !== 0) {
+    writer.writeInt64(
+      2,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional int64 forum_id = 1;
+ * @return {number}
+ */
+proto.workflows.Thread.prototype.getForumId = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.workflows.Thread} returns this
+ */
+proto.workflows.Thread.prototype.setForumId = function(value) {
+  return jspb.Message.setProto3IntField(this, 1, value);
+};
+
+
+/**
+ * optional int64 thread_id = 2;
+ * @return {number}
+ */
+proto.workflows.Thread.prototype.getThreadId = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.workflows.Thread} returns this
+ */
+proto.workflows.Thread.prototype.setThreadId = function(value) {
+  return jspb.Message.setProto3IntField(this, 2, value);
+};
+
+
+
+/**
+ * Oneof group definitions for this message. Each group defines the field
+ * numbers belonging to that group. When of these fields' value is set, all
+ * other fields in the group are cleared. During deserialization, if multiple
+ * fields are encountered for a group, only the last value seen will be kept.
+ * @private {!Array<!Array<number>>}
+ * @const
+ */
+proto.workflows.Action.oneofGroups_ = [[1,2,3,4,5,6,16,17,18,19,20,21]];
+
+/**
+ * @enum {number}
+ */
+proto.workflows.Action.ActionCase = {
+  ACTION_NOT_SET: 0,
+  REPLY_ACTION: 1,
+  MOVE_ACTION: 2,
+  MARK_DUPLICATE_ACTION: 3,
+  UNMARK_DUPLICATE_ACTION: 4,
+  ATTRIBUTE_ACTION: 5,
+  REPLY_WITH_CR_ACTION: 6,
+  STAR_ACTION: 16,
+  SUBSCRIBE_ACTION: 17,
+  VOTE_ACTION: 18,
+  REPORT_ACTION: 19,
+  MARK_AS_READ_ACTION: 20,
+  MARK_AS_UNREAD_ACTION: 21
+};
+
+/**
+ * @return {proto.workflows.Action.ActionCase}
+ */
+proto.workflows.Action.prototype.getActionCase = function() {
+  return /** @type {proto.workflows.Action.ActionCase} */(jspb.Message.computeOneofCase(this, proto.workflows.Action.oneofGroups_[0]));
+};
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    replyAction: (f = msg.getReplyAction()) && proto.workflows.Action.ReplyAction.toObject(includeInstance, f),
+    moveAction: (f = msg.getMoveAction()) && proto.workflows.Action.MoveAction.toObject(includeInstance, f),
+    markDuplicateAction: (f = msg.getMarkDuplicateAction()) && proto.workflows.Action.MarkDuplicateAction.toObject(includeInstance, f),
+    unmarkDuplicateAction: (f = msg.getUnmarkDuplicateAction()) && proto.workflows.Action.UnmarkDuplicateAction.toObject(includeInstance, f),
+    attributeAction: (f = msg.getAttributeAction()) && proto.workflows.Action.AttributeAction.toObject(includeInstance, f),
+    replyWithCrAction: (f = msg.getReplyWithCrAction()) && proto.workflows.Action.ReplyWithCRAction.toObject(includeInstance, f),
+    starAction: (f = msg.getStarAction()) && proto.workflows.Action.StarAction.toObject(includeInstance, f),
+    subscribeAction: (f = msg.getSubscribeAction()) && proto.workflows.Action.SubscribeAction.toObject(includeInstance, f),
+    voteAction: (f = msg.getVoteAction()) && proto.workflows.Action.VoteAction.toObject(includeInstance, f),
+    reportAction: (f = msg.getReportAction()) && proto.workflows.Action.ReportAction.toObject(includeInstance, f),
+    markAsReadAction: (f = msg.getMarkAsReadAction()) && proto.workflows.Action.MarkAsReadAction.toObject(includeInstance, f),
+    markAsUnreadAction: (f = msg.getMarkAsUnreadAction()) && proto.workflows.Action.MarkAsUnreadAction.toObject(includeInstance, f)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action}
+ */
+proto.workflows.Action.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action;
+  return proto.workflows.Action.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action}
+ */
+proto.workflows.Action.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = new proto.workflows.Action.ReplyAction;
+      reader.readMessage(value,proto.workflows.Action.ReplyAction.deserializeBinaryFromReader);
+      msg.setReplyAction(value);
+      break;
+    case 2:
+      var value = new proto.workflows.Action.MoveAction;
+      reader.readMessage(value,proto.workflows.Action.MoveAction.deserializeBinaryFromReader);
+      msg.setMoveAction(value);
+      break;
+    case 3:
+      var value = new proto.workflows.Action.MarkDuplicateAction;
+      reader.readMessage(value,proto.workflows.Action.MarkDuplicateAction.deserializeBinaryFromReader);
+      msg.setMarkDuplicateAction(value);
+      break;
+    case 4:
+      var value = new proto.workflows.Action.UnmarkDuplicateAction;
+      reader.readMessage(value,proto.workflows.Action.UnmarkDuplicateAction.deserializeBinaryFromReader);
+      msg.setUnmarkDuplicateAction(value);
+      break;
+    case 5:
+      var value = new proto.workflows.Action.AttributeAction;
+      reader.readMessage(value,proto.workflows.Action.AttributeAction.deserializeBinaryFromReader);
+      msg.setAttributeAction(value);
+      break;
+    case 6:
+      var value = new proto.workflows.Action.ReplyWithCRAction;
+      reader.readMessage(value,proto.workflows.Action.ReplyWithCRAction.deserializeBinaryFromReader);
+      msg.setReplyWithCrAction(value);
+      break;
+    case 16:
+      var value = new proto.workflows.Action.StarAction;
+      reader.readMessage(value,proto.workflows.Action.StarAction.deserializeBinaryFromReader);
+      msg.setStarAction(value);
+      break;
+    case 17:
+      var value = new proto.workflows.Action.SubscribeAction;
+      reader.readMessage(value,proto.workflows.Action.SubscribeAction.deserializeBinaryFromReader);
+      msg.setSubscribeAction(value);
+      break;
+    case 18:
+      var value = new proto.workflows.Action.VoteAction;
+      reader.readMessage(value,proto.workflows.Action.VoteAction.deserializeBinaryFromReader);
+      msg.setVoteAction(value);
+      break;
+    case 19:
+      var value = new proto.workflows.Action.ReportAction;
+      reader.readMessage(value,proto.workflows.Action.ReportAction.deserializeBinaryFromReader);
+      msg.setReportAction(value);
+      break;
+    case 20:
+      var value = new proto.workflows.Action.MarkAsReadAction;
+      reader.readMessage(value,proto.workflows.Action.MarkAsReadAction.deserializeBinaryFromReader);
+      msg.setMarkAsReadAction(value);
+      break;
+    case 21:
+      var value = new proto.workflows.Action.MarkAsUnreadAction;
+      reader.readMessage(value,proto.workflows.Action.MarkAsUnreadAction.deserializeBinaryFromReader);
+      msg.setMarkAsUnreadAction(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getReplyAction();
+  if (f != null) {
+    writer.writeMessage(
+      1,
+      f,
+      proto.workflows.Action.ReplyAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getMoveAction();
+  if (f != null) {
+    writer.writeMessage(
+      2,
+      f,
+      proto.workflows.Action.MoveAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getMarkDuplicateAction();
+  if (f != null) {
+    writer.writeMessage(
+      3,
+      f,
+      proto.workflows.Action.MarkDuplicateAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getUnmarkDuplicateAction();
+  if (f != null) {
+    writer.writeMessage(
+      4,
+      f,
+      proto.workflows.Action.UnmarkDuplicateAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getAttributeAction();
+  if (f != null) {
+    writer.writeMessage(
+      5,
+      f,
+      proto.workflows.Action.AttributeAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getReplyWithCrAction();
+  if (f != null) {
+    writer.writeMessage(
+      6,
+      f,
+      proto.workflows.Action.ReplyWithCRAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getStarAction();
+  if (f != null) {
+    writer.writeMessage(
+      16,
+      f,
+      proto.workflows.Action.StarAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getSubscribeAction();
+  if (f != null) {
+    writer.writeMessage(
+      17,
+      f,
+      proto.workflows.Action.SubscribeAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getVoteAction();
+  if (f != null) {
+    writer.writeMessage(
+      18,
+      f,
+      proto.workflows.Action.VoteAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getReportAction();
+  if (f != null) {
+    writer.writeMessage(
+      19,
+      f,
+      proto.workflows.Action.ReportAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getMarkAsReadAction();
+  if (f != null) {
+    writer.writeMessage(
+      20,
+      f,
+      proto.workflows.Action.MarkAsReadAction.serializeBinaryToWriter
+    );
+  }
+  f = message.getMarkAsUnreadAction();
+  if (f != null) {
+    writer.writeMessage(
+      21,
+      f,
+      proto.workflows.Action.MarkAsUnreadAction.serializeBinaryToWriter
+    );
+  }
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.ReplyAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.ReplyAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.ReplyAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.ReplyAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    payload: jspb.Message.getFieldWithDefault(msg, 1, ""),
+    subscribe: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
+    markAsAnswer: jspb.Message.getBooleanFieldWithDefault(msg, 3, false)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.ReplyAction}
+ */
+proto.workflows.Action.ReplyAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.ReplyAction;
+  return proto.workflows.Action.ReplyAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.ReplyAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.ReplyAction}
+ */
+proto.workflows.Action.ReplyAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setPayload(value);
+      break;
+    case 2:
+      var value = /** @type {boolean} */ (reader.readBool());
+      msg.setSubscribe(value);
+      break;
+    case 3:
+      var value = /** @type {boolean} */ (reader.readBool());
+      msg.setMarkAsAnswer(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.ReplyAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.ReplyAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.ReplyAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.ReplyAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getPayload();
+  if (f.length > 0) {
+    writer.writeString(
+      1,
+      f
+    );
+  }
+  f = message.getSubscribe();
+  if (f) {
+    writer.writeBool(
+      2,
+      f
+    );
+  }
+  f = message.getMarkAsAnswer();
+  if (f) {
+    writer.writeBool(
+      3,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional string payload = 1;
+ * @return {string}
+ */
+proto.workflows.Action.ReplyAction.prototype.getPayload = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.workflows.Action.ReplyAction} returns this
+ */
+proto.workflows.Action.ReplyAction.prototype.setPayload = function(value) {
+  return jspb.Message.setProto3StringField(this, 1, value);
+};
+
+
+/**
+ * optional bool subscribe = 2;
+ * @return {boolean}
+ */
+proto.workflows.Action.ReplyAction.prototype.getSubscribe = function() {
+  return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
+};
+
+
+/**
+ * @param {boolean} value
+ * @return {!proto.workflows.Action.ReplyAction} returns this
+ */
+proto.workflows.Action.ReplyAction.prototype.setSubscribe = function(value) {
+  return jspb.Message.setProto3BooleanField(this, 2, value);
+};
+
+
+/**
+ * optional bool mark_as_answer = 3;
+ * @return {boolean}
+ */
+proto.workflows.Action.ReplyAction.prototype.getMarkAsAnswer = function() {
+  return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 3, false));
+};
+
+
+/**
+ * @param {boolean} value
+ * @return {!proto.workflows.Action.ReplyAction} returns this
+ */
+proto.workflows.Action.ReplyAction.prototype.setMarkAsAnswer = function(value) {
+  return jspb.Message.setProto3BooleanField(this, 3, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.MoveAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.MoveAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.MoveAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MoveAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    forumId: jspb.Message.getFieldWithDefault(msg, 1, 0),
+    category: jspb.Message.getFieldWithDefault(msg, 2, ""),
+    language: jspb.Message.getFieldWithDefault(msg, 3, ""),
+    propertyMap: (f = msg.getPropertyMap()) ? f.toObject(includeInstance, undefined) : []
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.MoveAction}
+ */
+proto.workflows.Action.MoveAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.MoveAction;
+  return proto.workflows.Action.MoveAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.MoveAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.MoveAction}
+ */
+proto.workflows.Action.MoveAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {number} */ (reader.readInt64());
+      msg.setForumId(value);
+      break;
+    case 2:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setCategory(value);
+      break;
+    case 3:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setLanguage(value);
+      break;
+    case 4:
+      var value = msg.getPropertyMap();
+      reader.readMessage(value, function(message, reader) {
+        jspb.Map.deserializeBinary(message, reader, jspb.BinaryReader.prototype.readString, jspb.BinaryReader.prototype.readString, null, "", "");
+         });
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.MoveAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.MoveAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.MoveAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MoveAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getForumId();
+  if (f !== 0) {
+    writer.writeInt64(
+      1,
+      f
+    );
+  }
+  f = message.getCategory();
+  if (f.length > 0) {
+    writer.writeString(
+      2,
+      f
+    );
+  }
+  f = message.getLanguage();
+  if (f.length > 0) {
+    writer.writeString(
+      3,
+      f
+    );
+  }
+  f = message.getPropertyMap(true);
+  if (f && f.getLength() > 0) {
+    f.serializeBinary(4, writer, jspb.BinaryWriter.prototype.writeString, jspb.BinaryWriter.prototype.writeString);
+  }
+};
+
+
+/**
+ * optional int64 forum_id = 1;
+ * @return {number}
+ */
+proto.workflows.Action.MoveAction.prototype.getForumId = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.workflows.Action.MoveAction} returns this
+ */
+proto.workflows.Action.MoveAction.prototype.setForumId = function(value) {
+  return jspb.Message.setProto3IntField(this, 1, value);
+};
+
+
+/**
+ * optional string category = 2;
+ * @return {string}
+ */
+proto.workflows.Action.MoveAction.prototype.getCategory = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.workflows.Action.MoveAction} returns this
+ */
+proto.workflows.Action.MoveAction.prototype.setCategory = function(value) {
+  return jspb.Message.setProto3StringField(this, 2, value);
+};
+
+
+/**
+ * optional string language = 3;
+ * @return {string}
+ */
+proto.workflows.Action.MoveAction.prototype.getLanguage = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.workflows.Action.MoveAction} returns this
+ */
+proto.workflows.Action.MoveAction.prototype.setLanguage = function(value) {
+  return jspb.Message.setProto3StringField(this, 3, value);
+};
+
+
+/**
+ * map<string, string> property = 4;
+ * @param {boolean=} opt_noLazyCreate Do not create the map if
+ * empty, instead returning `undefined`
+ * @return {!jspb.Map<string,string>}
+ */
+proto.workflows.Action.MoveAction.prototype.getPropertyMap = function(opt_noLazyCreate) {
+  return /** @type {!jspb.Map<string,string>} */ (
+      jspb.Message.getMapField(this, 4, opt_noLazyCreate,
+      null));
+};
+
+
+/**
+ * Clears values from the map. The map will be non-null.
+ * @return {!proto.workflows.Action.MoveAction} returns this
+ */
+proto.workflows.Action.MoveAction.prototype.clearPropertyMap = function() {
+  this.getPropertyMap().clear();
+  return this;
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.MarkDuplicateAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.MarkDuplicateAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.MarkDuplicateAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MarkDuplicateAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    destination: (f = msg.getDestination()) && proto.workflows.Thread.toObject(includeInstance, f)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.MarkDuplicateAction}
+ */
+proto.workflows.Action.MarkDuplicateAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.MarkDuplicateAction;
+  return proto.workflows.Action.MarkDuplicateAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.MarkDuplicateAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.MarkDuplicateAction}
+ */
+proto.workflows.Action.MarkDuplicateAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = new proto.workflows.Thread;
+      reader.readMessage(value,proto.workflows.Thread.deserializeBinaryFromReader);
+      msg.setDestination(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.MarkDuplicateAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.MarkDuplicateAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.MarkDuplicateAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MarkDuplicateAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getDestination();
+  if (f != null) {
+    writer.writeMessage(
+      1,
+      f,
+      proto.workflows.Thread.serializeBinaryToWriter
+    );
+  }
+};
+
+
+/**
+ * optional Thread destination = 1;
+ * @return {?proto.workflows.Thread}
+ */
+proto.workflows.Action.MarkDuplicateAction.prototype.getDestination = function() {
+  return /** @type{?proto.workflows.Thread} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Thread, 1));
+};
+
+
+/**
+ * @param {?proto.workflows.Thread|undefined} value
+ * @return {!proto.workflows.Action.MarkDuplicateAction} returns this
+*/
+proto.workflows.Action.MarkDuplicateAction.prototype.setDestination = function(value) {
+  return jspb.Message.setWrapperField(this, 1, value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action.MarkDuplicateAction} returns this
+ */
+proto.workflows.Action.MarkDuplicateAction.prototype.clearDestination = function() {
+  return this.setDestination(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.MarkDuplicateAction.prototype.hasDestination = function() {
+  return jspb.Message.getField(this, 1) != null;
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.UnmarkDuplicateAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.UnmarkDuplicateAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.UnmarkDuplicateAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.UnmarkDuplicateAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.UnmarkDuplicateAction}
+ */
+proto.workflows.Action.UnmarkDuplicateAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.UnmarkDuplicateAction;
+  return proto.workflows.Action.UnmarkDuplicateAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.UnmarkDuplicateAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.UnmarkDuplicateAction}
+ */
+proto.workflows.Action.UnmarkDuplicateAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.UnmarkDuplicateAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.UnmarkDuplicateAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.UnmarkDuplicateAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.UnmarkDuplicateAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.ReplyWithCRAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.ReplyWithCRAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.ReplyWithCRAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    cannedResponseId: jspb.Message.getFieldWithDefault(msg, 1, 0),
+    subscribe: jspb.Message.getBooleanFieldWithDefault(msg, 2, false),
+    markAsAnswer: jspb.Message.getBooleanFieldWithDefault(msg, 3, false)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.ReplyWithCRAction}
+ */
+proto.workflows.Action.ReplyWithCRAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.ReplyWithCRAction;
+  return proto.workflows.Action.ReplyWithCRAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.ReplyWithCRAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.ReplyWithCRAction}
+ */
+proto.workflows.Action.ReplyWithCRAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {number} */ (reader.readInt64());
+      msg.setCannedResponseId(value);
+      break;
+    case 2:
+      var value = /** @type {boolean} */ (reader.readBool());
+      msg.setSubscribe(value);
+      break;
+    case 3:
+      var value = /** @type {boolean} */ (reader.readBool());
+      msg.setMarkAsAnswer(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.ReplyWithCRAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.ReplyWithCRAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.ReplyWithCRAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getCannedResponseId();
+  if (f !== 0) {
+    writer.writeInt64(
+      1,
+      f
+    );
+  }
+  f = message.getSubscribe();
+  if (f) {
+    writer.writeBool(
+      2,
+      f
+    );
+  }
+  f = message.getMarkAsAnswer();
+  if (f) {
+    writer.writeBool(
+      3,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional int64 canned_response_id = 1;
+ * @return {number}
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.getCannedResponseId = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.workflows.Action.ReplyWithCRAction} returns this
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.setCannedResponseId = function(value) {
+  return jspb.Message.setProto3IntField(this, 1, value);
+};
+
+
+/**
+ * optional bool subscribe = 2;
+ * @return {boolean}
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.getSubscribe = function() {
+  return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 2, false));
+};
+
+
+/**
+ * @param {boolean} value
+ * @return {!proto.workflows.Action.ReplyWithCRAction} returns this
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.setSubscribe = function(value) {
+  return jspb.Message.setProto3BooleanField(this, 2, value);
+};
+
+
+/**
+ * optional bool mark_as_answer = 3;
+ * @return {boolean}
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.getMarkAsAnswer = function() {
+  return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 3, false));
+};
+
+
+/**
+ * @param {boolean} value
+ * @return {!proto.workflows.Action.ReplyWithCRAction} returns this
+ */
+proto.workflows.Action.ReplyWithCRAction.prototype.setMarkAsAnswer = function(value) {
+  return jspb.Message.setProto3BooleanField(this, 3, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.StarAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.StarAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.StarAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.StarAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    star: jspb.Message.getBooleanFieldWithDefault(msg, 1, false)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.StarAction}
+ */
+proto.workflows.Action.StarAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.StarAction;
+  return proto.workflows.Action.StarAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.StarAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.StarAction}
+ */
+proto.workflows.Action.StarAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {boolean} */ (reader.readBool());
+      msg.setStar(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.StarAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.StarAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.StarAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.StarAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getStar();
+  if (f) {
+    writer.writeBool(
+      1,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional bool star = 1;
+ * @return {boolean}
+ */
+proto.workflows.Action.StarAction.prototype.getStar = function() {
+  return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 1, false));
+};
+
+
+/**
+ * @param {boolean} value
+ * @return {!proto.workflows.Action.StarAction} returns this
+ */
+proto.workflows.Action.StarAction.prototype.setStar = function(value) {
+  return jspb.Message.setProto3BooleanField(this, 1, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.SubscribeAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.SubscribeAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.SubscribeAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.SubscribeAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    subscribe: jspb.Message.getBooleanFieldWithDefault(msg, 1, false)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.SubscribeAction}
+ */
+proto.workflows.Action.SubscribeAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.SubscribeAction;
+  return proto.workflows.Action.SubscribeAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.SubscribeAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.SubscribeAction}
+ */
+proto.workflows.Action.SubscribeAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {boolean} */ (reader.readBool());
+      msg.setSubscribe(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.SubscribeAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.SubscribeAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.SubscribeAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.SubscribeAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getSubscribe();
+  if (f) {
+    writer.writeBool(
+      1,
+      f
+    );
+  }
+};
+
+
+/**
+ * optional bool subscribe = 1;
+ * @return {boolean}
+ */
+proto.workflows.Action.SubscribeAction.prototype.getSubscribe = function() {
+  return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 1, false));
+};
+
+
+/**
+ * @param {boolean} value
+ * @return {!proto.workflows.Action.SubscribeAction} returns this
+ */
+proto.workflows.Action.SubscribeAction.prototype.setSubscribe = function(value) {
+  return jspb.Message.setProto3BooleanField(this, 1, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.VoteAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.VoteAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.VoteAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.VoteAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    vote: jspb.Message.getFieldWithDefault(msg, 1, 0)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.VoteAction}
+ */
+proto.workflows.Action.VoteAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.VoteAction;
+  return proto.workflows.Action.VoteAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.VoteAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.VoteAction}
+ */
+proto.workflows.Action.VoteAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {!proto.workflows.Action.VoteAction.Vote} */ (reader.readEnum());
+      msg.setVote(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.VoteAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.VoteAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.VoteAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.VoteAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getVote();
+  if (f !== 0.0) {
+    writer.writeEnum(
+      1,
+      f
+    );
+  }
+};
+
+
+/**
+ * @enum {number}
+ */
+proto.workflows.Action.VoteAction.Vote = {
+  NONE: 0,
+  UP: 1,
+  DOWN: -1
+};
+
+/**
+ * optional Vote vote = 1;
+ * @return {!proto.workflows.Action.VoteAction.Vote}
+ */
+proto.workflows.Action.VoteAction.prototype.getVote = function() {
+  return /** @type {!proto.workflows.Action.VoteAction.Vote} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {!proto.workflows.Action.VoteAction.Vote} value
+ * @return {!proto.workflows.Action.VoteAction} returns this
+ */
+proto.workflows.Action.VoteAction.prototype.setVote = function(value) {
+  return jspb.Message.setProto3EnumField(this, 1, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.AttributeAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.AttributeAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.AttributeAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.AttributeAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    attributeAction: jspb.Message.getFieldWithDefault(msg, 1, 0)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.AttributeAction}
+ */
+proto.workflows.Action.AttributeAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.AttributeAction;
+  return proto.workflows.Action.AttributeAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.AttributeAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.AttributeAction}
+ */
+proto.workflows.Action.AttributeAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {!proto.workflows.Action.AttributeAction.AttributeAction} */ (reader.readEnum());
+      msg.setAttributeAction(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.AttributeAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.AttributeAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.AttributeAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.AttributeAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getAttributeAction();
+  if (f !== 0.0) {
+    writer.writeEnum(
+      1,
+      f
+    );
+  }
+};
+
+
+/**
+ * @enum {number}
+ */
+proto.workflows.Action.AttributeAction.AttributeAction = {
+  AA_NONE: 0,
+  AA_LOCK: 1,
+  AA_UNLOCK: 2,
+  AA_PIN: 3,
+  AA_UNPIN: 4,
+  AA_NON_ISSUE: 5,
+  AA_OBSOLETE: 6,
+  AA_REVERT: 7,
+  AA_SET_TRENDING: 8,
+  AA_UNSET_TRENDING: 9,
+  AA_SET_ISSUE_RESOLVED: 10,
+  AA_UNSET_ISSUE_RESOLVED: 11,
+  AA_SOFT_LOCK: 12,
+  AA_UNSOFT_LOCK: 13,
+  AA_EXCLUDE_FROM_GOLDEN: 14,
+  AA_UNEXCLUDE_FROM_GOLDEN: 15,
+  AA_INCLUDE_IN_GOLDEN: 16
+};
+
+/**
+ * optional AttributeAction attribute_action = 1;
+ * @return {!proto.workflows.Action.AttributeAction.AttributeAction}
+ */
+proto.workflows.Action.AttributeAction.prototype.getAttributeAction = function() {
+  return /** @type {!proto.workflows.Action.AttributeAction.AttributeAction} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {!proto.workflows.Action.AttributeAction.AttributeAction} value
+ * @return {!proto.workflows.Action.AttributeAction} returns this
+ */
+proto.workflows.Action.AttributeAction.prototype.setAttributeAction = function(value) {
+  return jspb.Message.setProto3EnumField(this, 1, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.ReportAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.ReportAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.ReportAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.ReportAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    reportType: jspb.Message.getFieldWithDefault(msg, 1, 0)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.ReportAction}
+ */
+proto.workflows.Action.ReportAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.ReportAction;
+  return proto.workflows.Action.ReportAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.ReportAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.ReportAction}
+ */
+proto.workflows.Action.ReportAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {!proto.workflows.Action.ReportAction.ReportType} */ (reader.readEnum());
+      msg.setReportType(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.ReportAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.ReportAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.ReportAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.ReportAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getReportType();
+  if (f !== 0.0) {
+    writer.writeEnum(
+      1,
+      f
+    );
+  }
+};
+
+
+/**
+ * @enum {number}
+ */
+proto.workflows.Action.ReportAction.ReportType = {
+  RT_UNKNOWN: 0,
+  RT_OFF_TOPIC: 1,
+  RT_ABUSE: 2
+};
+
+/**
+ * optional ReportType report_type = 1;
+ * @return {!proto.workflows.Action.ReportAction.ReportType}
+ */
+proto.workflows.Action.ReportAction.prototype.getReportType = function() {
+  return /** @type {!proto.workflows.Action.ReportAction.ReportType} */ (jspb.Message.getFieldWithDefault(this, 1, 0));
+};
+
+
+/**
+ * @param {!proto.workflows.Action.ReportAction.ReportType} value
+ * @return {!proto.workflows.Action.ReportAction} returns this
+ */
+proto.workflows.Action.ReportAction.prototype.setReportType = function(value) {
+  return jspb.Message.setProto3EnumField(this, 1, value);
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.MarkAsReadAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.MarkAsReadAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.MarkAsReadAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MarkAsReadAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.MarkAsReadAction}
+ */
+proto.workflows.Action.MarkAsReadAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.MarkAsReadAction;
+  return proto.workflows.Action.MarkAsReadAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.MarkAsReadAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.MarkAsReadAction}
+ */
+proto.workflows.Action.MarkAsReadAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.MarkAsReadAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.MarkAsReadAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.MarkAsReadAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MarkAsReadAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+};
+
+
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Action.MarkAsUnreadAction.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Action.MarkAsUnreadAction.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Action.MarkAsUnreadAction} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MarkAsUnreadAction.toObject = function(includeInstance, msg) {
+  var f, obj = {
+
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Action.MarkAsUnreadAction}
+ */
+proto.workflows.Action.MarkAsUnreadAction.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Action.MarkAsUnreadAction;
+  return proto.workflows.Action.MarkAsUnreadAction.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Action.MarkAsUnreadAction} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Action.MarkAsUnreadAction}
+ */
+proto.workflows.Action.MarkAsUnreadAction.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Action.MarkAsUnreadAction.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Action.MarkAsUnreadAction.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Action.MarkAsUnreadAction} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Action.MarkAsUnreadAction.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+};
+
+
+/**
+ * optional ReplyAction reply_action = 1;
+ * @return {?proto.workflows.Action.ReplyAction}
+ */
+proto.workflows.Action.prototype.getReplyAction = function() {
+  return /** @type{?proto.workflows.Action.ReplyAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.ReplyAction, 1));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.ReplyAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setReplyAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 1, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearReplyAction = function() {
+  return this.setReplyAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasReplyAction = function() {
+  return jspb.Message.getField(this, 1) != null;
+};
+
+
+/**
+ * optional MoveAction move_action = 2;
+ * @return {?proto.workflows.Action.MoveAction}
+ */
+proto.workflows.Action.prototype.getMoveAction = function() {
+  return /** @type{?proto.workflows.Action.MoveAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.MoveAction, 2));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.MoveAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setMoveAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 2, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearMoveAction = function() {
+  return this.setMoveAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasMoveAction = function() {
+  return jspb.Message.getField(this, 2) != null;
+};
+
+
+/**
+ * optional MarkDuplicateAction mark_duplicate_action = 3;
+ * @return {?proto.workflows.Action.MarkDuplicateAction}
+ */
+proto.workflows.Action.prototype.getMarkDuplicateAction = function() {
+  return /** @type{?proto.workflows.Action.MarkDuplicateAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.MarkDuplicateAction, 3));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.MarkDuplicateAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setMarkDuplicateAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 3, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearMarkDuplicateAction = function() {
+  return this.setMarkDuplicateAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasMarkDuplicateAction = function() {
+  return jspb.Message.getField(this, 3) != null;
+};
+
+
+/**
+ * optional UnmarkDuplicateAction unmark_duplicate_action = 4;
+ * @return {?proto.workflows.Action.UnmarkDuplicateAction}
+ */
+proto.workflows.Action.prototype.getUnmarkDuplicateAction = function() {
+  return /** @type{?proto.workflows.Action.UnmarkDuplicateAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.UnmarkDuplicateAction, 4));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.UnmarkDuplicateAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setUnmarkDuplicateAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 4, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearUnmarkDuplicateAction = function() {
+  return this.setUnmarkDuplicateAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasUnmarkDuplicateAction = function() {
+  return jspb.Message.getField(this, 4) != null;
+};
+
+
+/**
+ * optional AttributeAction attribute_action = 5;
+ * @return {?proto.workflows.Action.AttributeAction}
+ */
+proto.workflows.Action.prototype.getAttributeAction = function() {
+  return /** @type{?proto.workflows.Action.AttributeAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.AttributeAction, 5));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.AttributeAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setAttributeAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 5, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearAttributeAction = function() {
+  return this.setAttributeAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasAttributeAction = function() {
+  return jspb.Message.getField(this, 5) != null;
+};
+
+
+/**
+ * optional ReplyWithCRAction reply_with_cr_action = 6;
+ * @return {?proto.workflows.Action.ReplyWithCRAction}
+ */
+proto.workflows.Action.prototype.getReplyWithCrAction = function() {
+  return /** @type{?proto.workflows.Action.ReplyWithCRAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.ReplyWithCRAction, 6));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.ReplyWithCRAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setReplyWithCrAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 6, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearReplyWithCrAction = function() {
+  return this.setReplyWithCrAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasReplyWithCrAction = function() {
+  return jspb.Message.getField(this, 6) != null;
+};
+
+
+/**
+ * optional StarAction star_action = 16;
+ * @return {?proto.workflows.Action.StarAction}
+ */
+proto.workflows.Action.prototype.getStarAction = function() {
+  return /** @type{?proto.workflows.Action.StarAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.StarAction, 16));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.StarAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setStarAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 16, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearStarAction = function() {
+  return this.setStarAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasStarAction = function() {
+  return jspb.Message.getField(this, 16) != null;
+};
+
+
+/**
+ * optional SubscribeAction subscribe_action = 17;
+ * @return {?proto.workflows.Action.SubscribeAction}
+ */
+proto.workflows.Action.prototype.getSubscribeAction = function() {
+  return /** @type{?proto.workflows.Action.SubscribeAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.SubscribeAction, 17));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.SubscribeAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setSubscribeAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 17, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearSubscribeAction = function() {
+  return this.setSubscribeAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasSubscribeAction = function() {
+  return jspb.Message.getField(this, 17) != null;
+};
+
+
+/**
+ * optional VoteAction vote_action = 18;
+ * @return {?proto.workflows.Action.VoteAction}
+ */
+proto.workflows.Action.prototype.getVoteAction = function() {
+  return /** @type{?proto.workflows.Action.VoteAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.VoteAction, 18));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.VoteAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setVoteAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 18, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearVoteAction = function() {
+  return this.setVoteAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasVoteAction = function() {
+  return jspb.Message.getField(this, 18) != null;
+};
+
+
+/**
+ * optional ReportAction report_action = 19;
+ * @return {?proto.workflows.Action.ReportAction}
+ */
+proto.workflows.Action.prototype.getReportAction = function() {
+  return /** @type{?proto.workflows.Action.ReportAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.ReportAction, 19));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.ReportAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setReportAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 19, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearReportAction = function() {
+  return this.setReportAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasReportAction = function() {
+  return jspb.Message.getField(this, 19) != null;
+};
+
+
+/**
+ * optional MarkAsReadAction mark_as_read_action = 20;
+ * @return {?proto.workflows.Action.MarkAsReadAction}
+ */
+proto.workflows.Action.prototype.getMarkAsReadAction = function() {
+  return /** @type{?proto.workflows.Action.MarkAsReadAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.MarkAsReadAction, 20));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.MarkAsReadAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setMarkAsReadAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 20, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearMarkAsReadAction = function() {
+  return this.setMarkAsReadAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasMarkAsReadAction = function() {
+  return jspb.Message.getField(this, 20) != null;
+};
+
+
+/**
+ * optional MarkAsUnreadAction mark_as_unread_action = 21;
+ * @return {?proto.workflows.Action.MarkAsUnreadAction}
+ */
+proto.workflows.Action.prototype.getMarkAsUnreadAction = function() {
+  return /** @type{?proto.workflows.Action.MarkAsUnreadAction} */ (
+    jspb.Message.getWrapperField(this, proto.workflows.Action.MarkAsUnreadAction, 21));
+};
+
+
+/**
+ * @param {?proto.workflows.Action.MarkAsUnreadAction|undefined} value
+ * @return {!proto.workflows.Action} returns this
+*/
+proto.workflows.Action.prototype.setMarkAsUnreadAction = function(value) {
+  return jspb.Message.setOneofWrapperField(this, 21, proto.workflows.Action.oneofGroups_[0], value);
+};
+
+
+/**
+ * Clears the message field making it undefined.
+ * @return {!proto.workflows.Action} returns this
+ */
+proto.workflows.Action.prototype.clearMarkAsUnreadAction = function() {
+  return this.setMarkAsUnreadAction(undefined);
+};
+
+
+/**
+ * Returns whether this field is set.
+ * @return {boolean}
+ */
+proto.workflows.Action.prototype.hasMarkAsUnreadAction = function() {
+  return jspb.Message.getField(this, 21) != null;
+};
+
+
+
+/**
+ * List of repeated fields within this message type.
+ * @private {!Array<number>}
+ * @const
+ */
+proto.workflows.Workflow.repeatedFields_ = [4];
+
+
+
+if (jspb.Message.GENERATE_TO_OBJECT) {
+/**
+ * Creates an object representation of this proto.
+ * Field names that are reserved in JavaScript and will be renamed to pb_name.
+ * Optional fields that are not set will be set to undefined.
+ * To access a reserved field use, foo.pb_<name>, eg, foo.pb_default.
+ * For the list of reserved names please see:
+ *     net/proto2/compiler/js/internal/generator.cc#kKeyword.
+ * @param {boolean=} opt_includeInstance Deprecated. whether to include the
+ *     JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @return {!Object}
+ */
+proto.workflows.Workflow.prototype.toObject = function(opt_includeInstance) {
+  return proto.workflows.Workflow.toObject(opt_includeInstance, this);
+};
+
+
+/**
+ * Static version of the {@see toObject} method.
+ * @param {boolean|undefined} includeInstance Deprecated. Whether to include
+ *     the JSPB instance for transitional soy proto support:
+ *     http://goto/soy-param-migration
+ * @param {!proto.workflows.Workflow} msg The msg instance to transform.
+ * @return {!Object}
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Workflow.toObject = function(includeInstance, msg) {
+  var f, obj = {
+    name: jspb.Message.getFieldWithDefault(msg, 1, ""),
+    description: jspb.Message.getFieldWithDefault(msg, 2, ""),
+    index: jspb.Message.getFieldWithDefault(msg, 3, 0),
+    actionsList: jspb.Message.toObjectList(msg.getActionsList(),
+    proto.workflows.Action.toObject, includeInstance)
+  };
+
+  if (includeInstance) {
+    obj.$jspbMessageInstance = msg;
+  }
+  return obj;
+};
+}
+
+
+/**
+ * Deserializes binary data (in protobuf wire format).
+ * @param {jspb.ByteSource} bytes The bytes to deserialize.
+ * @return {!proto.workflows.Workflow}
+ */
+proto.workflows.Workflow.deserializeBinary = function(bytes) {
+  var reader = new jspb.BinaryReader(bytes);
+  var msg = new proto.workflows.Workflow;
+  return proto.workflows.Workflow.deserializeBinaryFromReader(msg, reader);
+};
+
+
+/**
+ * Deserializes binary data (in protobuf wire format) from the
+ * given reader into the given message object.
+ * @param {!proto.workflows.Workflow} msg The message object to deserialize into.
+ * @param {!jspb.BinaryReader} reader The BinaryReader to use.
+ * @return {!proto.workflows.Workflow}
+ */
+proto.workflows.Workflow.deserializeBinaryFromReader = function(msg, reader) {
+  while (reader.nextField()) {
+    if (reader.isEndGroup()) {
+      break;
+    }
+    var field = reader.getFieldNumber();
+    switch (field) {
+    case 1:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setName(value);
+      break;
+    case 2:
+      var value = /** @type {string} */ (reader.readString());
+      msg.setDescription(value);
+      break;
+    case 3:
+      var value = /** @type {number} */ (reader.readInt32());
+      msg.setIndex(value);
+      break;
+    case 4:
+      var value = new proto.workflows.Action;
+      reader.readMessage(value,proto.workflows.Action.deserializeBinaryFromReader);
+      msg.addActions(value);
+      break;
+    default:
+      reader.skipField();
+      break;
+    }
+  }
+  return msg;
+};
+
+
+/**
+ * Serializes the message to binary data (in protobuf wire format).
+ * @return {!Uint8Array}
+ */
+proto.workflows.Workflow.prototype.serializeBinary = function() {
+  var writer = new jspb.BinaryWriter();
+  proto.workflows.Workflow.serializeBinaryToWriter(this, writer);
+  return writer.getResultBuffer();
+};
+
+
+/**
+ * Serializes the given message to binary data (in protobuf wire
+ * format), writing to the given BinaryWriter.
+ * @param {!proto.workflows.Workflow} message
+ * @param {!jspb.BinaryWriter} writer
+ * @suppress {unusedLocalVariables} f is only used for nested messages
+ */
+proto.workflows.Workflow.serializeBinaryToWriter = function(message, writer) {
+  var f = undefined;
+  f = message.getName();
+  if (f.length > 0) {
+    writer.writeString(
+      1,
+      f
+    );
+  }
+  f = message.getDescription();
+  if (f.length > 0) {
+    writer.writeString(
+      2,
+      f
+    );
+  }
+  f = message.getIndex();
+  if (f !== 0) {
+    writer.writeInt32(
+      3,
+      f
+    );
+  }
+  f = message.getActionsList();
+  if (f.length > 0) {
+    writer.writeRepeatedMessage(
+      4,
+      f,
+      proto.workflows.Action.serializeBinaryToWriter
+    );
+  }
+};
+
+
+/**
+ * optional string name = 1;
+ * @return {string}
+ */
+proto.workflows.Workflow.prototype.getName = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.workflows.Workflow} returns this
+ */
+proto.workflows.Workflow.prototype.setName = function(value) {
+  return jspb.Message.setProto3StringField(this, 1, value);
+};
+
+
+/**
+ * optional string description = 2;
+ * @return {string}
+ */
+proto.workflows.Workflow.prototype.getDescription = function() {
+  return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, ""));
+};
+
+
+/**
+ * @param {string} value
+ * @return {!proto.workflows.Workflow} returns this
+ */
+proto.workflows.Workflow.prototype.setDescription = function(value) {
+  return jspb.Message.setProto3StringField(this, 2, value);
+};
+
+
+/**
+ * optional int32 index = 3;
+ * @return {number}
+ */
+proto.workflows.Workflow.prototype.getIndex = function() {
+  return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0));
+};
+
+
+/**
+ * @param {number} value
+ * @return {!proto.workflows.Workflow} returns this
+ */
+proto.workflows.Workflow.prototype.setIndex = function(value) {
+  return jspb.Message.setProto3IntField(this, 3, value);
+};
+
+
+/**
+ * repeated Action actions = 4;
+ * @return {!Array<!proto.workflows.Action>}
+ */
+proto.workflows.Workflow.prototype.getActionsList = function() {
+  return /** @type{!Array<!proto.workflows.Action>} */ (
+    jspb.Message.getRepeatedWrapperField(this, proto.workflows.Action, 4));
+};
+
+
+/**
+ * @param {!Array<!proto.workflows.Action>} value
+ * @return {!proto.workflows.Workflow} returns this
+*/
+proto.workflows.Workflow.prototype.setActionsList = function(value) {
+  return jspb.Message.setRepeatedWrapperField(this, 4, value);
+};
+
+
+/**
+ * @param {!proto.workflows.Action=} opt_value
+ * @param {number=} opt_index
+ * @return {!proto.workflows.Action}
+ */
+proto.workflows.Workflow.prototype.addActions = function(opt_value, opt_index) {
+  return jspb.Message.addToRepeatedWrapperField(this, 4, opt_value, proto.workflows.Action, opt_index);
+};
+
+
+/**
+ * Clears the list making it empty but non-null.
+ * @return {!proto.workflows.Workflow} returns this
+ */
+proto.workflows.Workflow.prototype.clearActionsList = function() {
+  return this.setActionsList([]);
+};
+
+
+goog.object.extend(exports, proto);
diff --git a/src/features/workflows/core/workflowsStorage.js b/src/features/workflows/core/workflowsStorage.js
new file mode 100644
index 0000000..3fc6f95
--- /dev/null
+++ b/src/features/workflows/core/workflowsStorage.js
@@ -0,0 +1,104 @@
+import {arrayBufferToBase64} from './common.js';
+import * as pb from './proto/main_pb.js';
+
+export const kWorkflowsDataKey = 'workflowsData';
+
+export default class WorkflowsStorage {
+  static watch(callback, asProtobuf = false) {
+    // Function which will be called when the watcher is initialized and every
+    // time the workflows storage changes.
+    const callOnChanged = () => {
+      this.getAll(asProtobuf).then(workflows => callback(workflows));
+    };
+
+    chrome.storage.onChanged.addListener((changes, areaName) => {
+      if (areaName == 'local' && changes[kWorkflowsDataKey]) callOnChanged();
+    });
+
+    callOnChanged();
+  }
+
+  static convertRawListToProtobuf(workflows) {
+    workflows.forEach(w => {
+      w.proto = pb.workflows.Workflow.deserializeBinary(w?.data);
+      delete w.data;
+    });
+  }
+
+  static getAll(asProtobuf = false) {
+    return new Promise(res => {
+      chrome.storage.local.get(kWorkflowsDataKey, items => {
+        const workflows = items[kWorkflowsDataKey];
+        if (!Array.isArray(workflows)) return res([]);
+        if (!asProtobuf) return res(workflows);
+
+        this.convertRawListToProtobuf(workflows);
+        return res(workflows);
+      });
+    });
+  }
+
+  static get(uuid, asProtobuf = false) {
+    return this.getAll(asProtobuf).then(workflows => {
+      for (const w of workflows) {
+        if (w.uuid == uuid) return w;
+      }
+      return null;
+    });
+  }
+
+  static exists(uuid) {
+    return this.get(uuid).then(w => w !== null);
+  }
+
+  static addRaw(base64Workflow) {
+    const w = {
+      uuid: self.crypto.randomUUID(),
+      data: base64Workflow,
+    };
+    return this.getAll().then(workflows => {
+      workflows.push(w);
+      const items = {};
+      items[kWorkflowsDataKey] = workflows;
+      chrome.storage.local.set(items);
+    });
+  }
+
+  static add(workflow) {
+    return this._proto2Base64(workflow).then(data => {
+      return this.addRaw(data);
+    });
+  }
+
+  static updateRaw(uuid, base64Workflow) {
+    return this.getAll().then(workflows => {
+      workflows.map(w => {
+        if (w.uuid !== uuid) return w;
+        w.data = base64Workflow;
+        return w;
+      });
+      const items = {};
+      items[kWorkflowsDataKey] = workflows;
+      chrome.storage.local.set(items);
+    });
+  }
+
+  static update(uuid, workflow) {
+    return this._proto2Base64(workflow).then(data => {
+      return this.updateRaw(uuid, data);
+    });
+  }
+
+  static remove(uuid) {
+    return this.getAll().then(workflows => {
+      const items = {};
+      items[kWorkflowsDataKey] = workflows.filter(w => w.uuid != uuid);
+      chrome.storage.local.set(items);
+    });
+  }
+
+  static _proto2Base64(workflow) {
+    const binaryWorkflow = workflow.serializeBinary();
+    return arrayBufferToBase64(binaryWorkflow);
+  }
+}
diff --git a/src/features/workflows/nodeWatcherHandlers/crTags.handler.ts b/src/features/workflows/nodeWatcherHandlers/crTags.handler.ts
new file mode 100644
index 0000000..27c10d9
--- /dev/null
+++ b/src/features/workflows/nodeWatcherHandlers/crTags.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import { WorkflowsNodeWatcherDependencies } from '../scripts/nodeWatcher.script';
+
+/**
+ * Injects the button to import a canned response next to each CR.
+ */
+export default class WorkflowsImportCRTagsHandler extends CssSelectorNodeWatcherScriptHandler<WorkflowsNodeWatcherDependencies> {
+  cssSelector = 'ec-canned-response-row .tags';
+
+  onMutatedNode(mutation: NodeMutation) {
+    this.options.workflowsImport.addButtonIfEnabled(mutation.node);
+  }
+}
diff --git a/src/features/workflows/nodeWatcherHandlers/threadListActionBar.handler.ts b/src/features/workflows/nodeWatcherHandlers/threadListActionBar.handler.ts
new file mode 100644
index 0000000..ff62487
--- /dev/null
+++ b/src/features/workflows/nodeWatcherHandlers/threadListActionBar.handler.ts
@@ -0,0 +1,20 @@
+import { NodeWatcherScriptHandler } from '../../../common/architecture/scripts/nodeWatcher/handlers/NodeWatcherScriptHandler';
+import { NodeMutation } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import { WorkflowsNodeWatcherDependencies } from '../scripts/nodeWatcher.script';
+
+/**
+ * Injects the workflows menu in the thread list.
+ */
+export default class WorkflowsThreadListActionBarHandler extends NodeWatcherScriptHandler<WorkflowsNodeWatcherDependencies> {
+  initialDiscoverySelector =
+    ':is(ec-bulk-actions material-button[debugid="mark-read-button"],' +
+    'ec-bulk-actions material-button[debugid="mark-unread-button"])';
+
+  nodeFilter(mutation: NodeMutation) {
+    return this.options.workflows.shouldAddThreadListBtn(mutation.node);
+  }
+
+  onMutatedNode(mutation: NodeMutation) {
+    this.options.workflows.addThreadListBtnIfEnabled(mutation.node);
+  }
+}
diff --git a/src/features/workflows/scripts/dependenciesSetUpAtMain.script.ts b/src/features/workflows/scripts/dependenciesSetUpAtMain.script.ts
new file mode 100644
index 0000000..37ec85f
--- /dev/null
+++ b/src/features/workflows/scripts/dependenciesSetUpAtMain.script.ts
@@ -0,0 +1,17 @@
+import {
+  Dependency,
+  WorkflowsDependency,
+} from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import SetUpDependenciesScript from '../../../common/architecture/scripts/setUpDependencies/SetUpDependenciesScript';
+
+export default class WorkflowsDependenciesSetUpAtMainScript extends SetUpDependenciesScript {
+  public page = ScriptPage.CommunityConsole;
+  public environment = ScriptEnvironment.ContentScript;
+  public runPhase = ScriptRunPhase.Main;
+  public dependencies: Dependency[] = [WorkflowsDependency];
+}
diff --git a/src/features/workflows/scripts/dependenciesSetUpAtStart.script.ts b/src/features/workflows/scripts/dependenciesSetUpAtStart.script.ts
new file mode 100644
index 0000000..0b52082
--- /dev/null
+++ b/src/features/workflows/scripts/dependenciesSetUpAtStart.script.ts
@@ -0,0 +1,18 @@
+import {
+  Dependency,
+  WorkflowsImportDependency,
+} from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import SetUpDependenciesScript from '../../../common/architecture/scripts/setUpDependencies/SetUpDependenciesScript';
+
+export default class WorkflowsDependenciesSetUpAtStartScript extends SetUpDependenciesScript {
+  public priority = 102;
+  public page = ScriptPage.CommunityConsole;
+  public environment = ScriptEnvironment.ContentScript;
+  public runPhase = ScriptRunPhase.Start;
+  public dependencies: Dependency[] = [WorkflowsImportDependency];
+}
diff --git a/src/features/workflows/scripts/nodeWatcher.script.ts b/src/features/workflows/scripts/nodeWatcher.script.ts
new file mode 100644
index 0000000..8d26c86
--- /dev/null
+++ b/src/features/workflows/scripts/nodeWatcher.script.ts
@@ -0,0 +1,39 @@
+import DependenciesProviderSingleton, {
+  WorkflowsDependency,
+  WorkflowsImportDependency,
+} from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import NodeWatcherScript from '../../../common/architecture/scripts/nodeWatcher/NodeWatcherScript';
+import WorkflowsImport from '../core/communityConsole/import';
+import Workflows from '../core/communityConsole/workflows';
+import WorkflowsImportCRTagsHandler from '../nodeWatcherHandlers/crTags.handler';
+import WorkflowsThreadListActionBarHandler from '../nodeWatcherHandlers/threadListActionBar.handler';
+
+export interface WorkflowsNodeWatcherDependencies {
+  workflows: Workflows;
+  workflowsImport: WorkflowsImport;
+}
+
+export default class WorkflowsNodeWatcherScript extends NodeWatcherScript<WorkflowsNodeWatcherDependencies> {
+  public page = ScriptPage.CommunityConsole;
+  public environment = ScriptEnvironment.ContentScript;
+  public runPhase = ScriptRunPhase.Main;
+  public handlers = new Map([
+    ['workflowsImportCRTags', WorkflowsImportCRTagsHandler],
+    ['workflowsThreadListActionBar', WorkflowsThreadListActionBarHandler],
+  ]);
+
+  protected optionsFactory(): WorkflowsNodeWatcherDependencies {
+    const dependenciesProvider = DependenciesProviderSingleton.getInstance();
+    return {
+      workflows: dependenciesProvider.getDependency(WorkflowsDependency),
+      workflowsImport: dependenciesProvider.getDependency(
+        WorkflowsImportDependency,
+      ),
+    };
+  }
+}
diff --git a/src/features/workflows/workflows.feature.ts b/src/features/workflows/workflows.feature.ts
new file mode 100644
index 0000000..7a6976c
--- /dev/null
+++ b/src/features/workflows/workflows.feature.ts
@@ -0,0 +1,17 @@
+import Feature from '../../common/architecture/features/Feature';
+import { ConcreteScript } from '../../common/architecture/scripts/Script';
+import { OptionCodename } from '../../common/optionsPrototype';
+import WorkflowsDependenciesSetUpAtMainScript from './scripts/dependenciesSetUpAtMain.script';
+import WorkflowsDependenciesSetUpAtStartScript from './scripts/dependenciesSetUpAtStart.script';
+import WorkflowsNodeWatcherScript from './scripts/nodeWatcher.script';
+
+export default class WorkflowsFeature extends Feature {
+  public readonly scripts: ConcreteScript[] = [
+    WorkflowsDependenciesSetUpAtStartScript,
+    WorkflowsDependenciesSetUpAtMainScript,
+    WorkflowsNodeWatcherScript,
+  ];
+
+  readonly codename = 'workflows';
+  readonly relatedOptions: OptionCodename[] = ['workflows'];
+}