diff --git a/package.json b/package.json
index 905e371b9dbec7ffbe04970c91393d1682a2487f..b0564c305de9ac9bdcdd2100c794a7e97dbdb30b 100644
--- a/package.json
+++ b/package.json
@@ -111,7 +111,7 @@
     "scratch-render": "0.1.0-prerelease.20190605151415",
     "scratch-storage": "1.3.2",
     "scratch-svg-renderer": "0.2.0-prerelease.20190523193400",
-    "scratch-vm": "0.2.0-prerelease.20190610152034",
+    "scratch-vm": "0.2.0-prerelease.20190619042313",
     "selenium-webdriver": "3.6.0",
     "startaudiocontext": "1.2.1",
     "style-loader": "^0.23.0",
diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx
index d79c7e9c72bc867b96d77c42f3a2382c4a95c421..fb01ce15d5dd321908d8fc38960e7b3ff1d65244 100644
--- a/src/containers/blocks.jsx
+++ b/src/containers/blocks.jsx
@@ -17,6 +17,7 @@ import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
 import {STAGE_DISPLAY_SIZES} from '../lib/layout-constants';
 import DropAreaHOC from '../lib/drop-area-hoc.jsx';
 import DragConstants from '../lib/drag-constants';
+import defineDynamicBlock from '../lib/define-dynamic-block';
 
 import {connect} from 'react-redux';
 import {updateToolbox} from '../reducers/toolbox';
@@ -393,20 +394,50 @@ class Blocks extends React.Component {
         // workspace to be 'undone' here.
         this.workspace.clearUndo();
     }
-    handleExtensionAdded (blocksInfo) {
-        // select JSON from each block info object then reject the pseudo-blocks which don't have JSON, like separators
-        // this actually defines blocks and MUST run regardless of the UI state
-        this.ScratchBlocks.defineBlocksWithJsonArray(blocksInfo.map(blockInfo => blockInfo.json).filter(x => x));
+    handleExtensionAdded (categoryInfo) {
+        const defineBlocks = blockInfoArray => {
+            if (blockInfoArray && blockInfoArray.length > 0) {
+                const staticBlocksJson = [];
+                const dynamicBlocksInfo = [];
+                blockInfoArray.forEach(blockInfo => {
+                    if (blockInfo.info && blockInfo.info.isDynamic) {
+                        dynamicBlocksInfo.push(blockInfo);
+                    } else if (blockInfo.json) {
+                        staticBlocksJson.push(blockInfo.json);
+                    }
+                    // otherwise it's a non-block entry such as '---'
+                });
+
+                this.ScratchBlocks.defineBlocksWithJsonArray(staticBlocksJson);
+                dynamicBlocksInfo.forEach(blockInfo => {
+                    // This is creating the block factory / constructor -- NOT a specific instance of the block.
+                    // The factory should only know static info about the block: the category info and the opcode.
+                    // Anything else will be picked up from the XML attached to the block instance.
+                    const extendedOpcode = `${categoryInfo.id}_${blockInfo.info.opcode}`;
+                    const blockDefinition =
+                        defineDynamicBlock(this.ScratchBlocks, categoryInfo, blockInfo, extendedOpcode);
+                    this.ScratchBlocks.Blocks[extendedOpcode] = blockDefinition;
+                });
+            }
+        };
+
+        // scratch-blocks implements a menu or custom field as a special kind of block ("shadow" block)
+        // these actually define blocks and MUST run regardless of the UI state
+        defineBlocks(
+            Object.getOwnPropertyNames(categoryInfo.customFieldTypes)
+                .map(fieldTypeName => categoryInfo.customFieldTypes[fieldTypeName].scratchBlocksDefinition));
+        defineBlocks(categoryInfo.menus);
+        defineBlocks(categoryInfo.blocks);
 
-        // Update the toolbox with new blocks
+        // Update the toolbox with new blocks if possible
         const toolboxXML = this.getToolboxXML();
         if (toolboxXML) {
             this.props.updateToolboxState(toolboxXML);
         }
     }
-    handleBlocksInfoUpdate (blocksInfo) {
+    handleBlocksInfoUpdate (categoryInfo) {
         // @todo Later we should replace this to avoid all the warnings from redefining blocks.
-        this.handleExtensionAdded(blocksInfo);
+        this.handleExtensionAdded(categoryInfo);
     }
     handleCategorySelected (categoryId) {
         const extension = extensionData.find(ext => ext.extensionId === categoryId);
diff --git a/src/lib/define-dynamic-block.js b/src/lib/define-dynamic-block.js
new file mode 100644
index 0000000000000000000000000000000000000000..33f1c8732cda4fdb88ae31a366dfcadf51ef3eca
--- /dev/null
+++ b/src/lib/define-dynamic-block.js
@@ -0,0 +1,108 @@
+// TODO: access `BlockType` and `ArgumentType` without reaching into VM
+// Should we move these into a new extension support module or something?
+import ArgumentType from 'scratch-vm/src/extension-support/argument-type';
+import BlockType from 'scratch-vm/src/extension-support/block-type';
+
+/**
+ * Define a block using extension info which has the ability to dynamically determine (and update) its layout.
+ * This functionality is used for extension blocks which can change its properties based on different state
+ * information. For example, the `control_stop` block changes its shape based on which menu item is selected
+ * and a variable block changes its text to reflect the variable name without using an editable field.
+ * @param {object} ScratchBlocks - The ScratchBlocks name space.
+ * @param {object} categoryInfo - Information about this block's extension category, including any menus and icons.
+ * @param {object} staticBlockInfo - The base block information before any dynamic changes.
+ * @param {string} extendedOpcode - The opcode for the block (including the extension ID).
+ */
+// TODO: grow this until it can fully replace `_convertForScratchBlocks` in the VM runtime
+const defineDynamicBlock = (ScratchBlocks, categoryInfo, staticBlockInfo, extendedOpcode) => ({
+    init: function () {
+        const blockJson = {
+            type: extendedOpcode,
+            inputsInline: true,
+            category: categoryInfo.name,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3
+        };
+        // There is a scratch-blocks / Blockly extension called "scratch_extension" which adjusts the styling of
+        // blocks to allow for an icon, a feature of Scratch extension blocks. However, Scratch "core" extension
+        // blocks don't have icons and so they should not use 'scratch_extension'. Adding a scratch-blocks / Blockly
+        // extension after `jsonInit` isn't fully supported (?), so we decide now whether there will be an icon.
+        if (staticBlockInfo.blockIconURI || categoryInfo.blockIconURI) {
+            blockJson.extensions = ['scratch_extension'];
+        }
+        // initialize the basics of the block, to be overridden & extended later by `domToMutation`
+        this.jsonInit(blockJson);
+        // initialize the cached block info used to carry block info from `domToMutation` to `mutationToDom`
+        this.blockInfoText = '{}';
+        // we need a block info update (through `domToMutation`) before we have a completely initialized block
+        this.needsBlockInfoUpdate = true;
+    },
+    mutationToDom: function () {
+        const container = document.createElement('mutation');
+        container.setAttribute('blockInfo', this.blockInfoText);
+        return container;
+    },
+    domToMutation: function (xmlElement) {
+        const blockInfoText = xmlElement.getAttribute('blockInfo');
+        if (!blockInfoText) return;
+        if (!this.needsBlockInfoUpdate) {
+            throw new Error('Attempted to update block info twice');
+        }
+        delete this.needsBlockInfoUpdate;
+        this.blockInfoText = blockInfoText;
+        const blockInfo = JSON.parse(blockInfoText);
+
+        switch (blockInfo.blockType) {
+        case BlockType.COMMAND:
+        case BlockType.CONDITIONAL:
+        case BlockType.LOOP:
+            this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_SQUARE);
+            this.setPreviousStatement(true);
+            this.setNextStatement(!blockInfo.isTerminal);
+            break;
+        case BlockType.REPORTER:
+            this.setOutput(true);
+            this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_ROUND);
+            if (!blockInfo.disableMonitor) {
+                this.setCheckboxInFlyout(true);
+            }
+            break;
+        case BlockType.BOOLEAN:
+            this.setOutput(true);
+            this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_HEXAGONAL);
+            break;
+        case BlockType.HAT:
+        case BlockType.EVENT:
+            this.setOutputShape(ScratchBlocks.OUTPUT_SHAPE_SQUARE);
+            this.setNextStatement(true);
+            break;
+        }
+
+        if (blockInfo.color1 || blockInfo.color2 || blockInfo.color3) {
+            // `setColour` handles undefined parameters by adjusting defined colors
+            this.setColour(blockInfo.color1, blockInfo.color2, blockInfo.color3);
+        }
+
+        // Layout block arguments
+        // TODO handle E/C Blocks
+        const blockText = blockInfo.text;
+        const args = [];
+        let argCount = 0;
+        const scratchBlocksStyleText = blockText.replace(/\[(.+?)]/g, (match, argName) => {
+            const arg = blockInfo.arguments[argName];
+            switch (arg.type) {
+            case ArgumentType.STRING:
+                args.push({type: 'input_value', name: argName});
+                break;
+            case ArgumentType.BOOLEAN:
+                args.push({type: 'input_value', name: argName, check: 'Boolean'});
+                break;
+            }
+            return `%${++argCount}`;
+        });
+        this.interpolate_(scratchBlocksStyleText, args);
+    }
+});
+
+export default defineDynamicBlock;
diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js
index 3443e05d2c0b5f5b763f28a5a146c10940251c5b..79cf35cc25e872931383a3dc3d0ceb955664812a 100644
--- a/src/lib/make-toolbox-xml.js
+++ b/src/lib/make-toolbox-xml.js
@@ -716,13 +716,16 @@ const xmlClose = '</xml>';
 /**
  * @param {!boolean} isStage - Whether the toolbox is for a stage-type target.
  * @param {?string} targetId - The current editing target
- * @param {?string} categoriesXML - null for default toolbox, or an XML string with <category> elements.
+ * @param {?Array.<object>} categoriesXML - optional array of `{id,xml}` for categories. This can include both core
+ * and other extensions: core extensions will be placed in the normal Scratch order; others will go at the bottom.
+ * @property {string} id - the extension / category ID.
+ * @property {string} xml - the `<category>...</category>` XML for this extension / category.
  * @param {?string} costumeName - The name of the default selected costume dropdown.
  * @param {?string} backdropName - The name of the default selected backdrop dropdown.
  * @param {?string} soundName -  The name of the default selected sound dropdown.
  * @returns {string} - a ScratchBlocks-style XML document for the contents of the toolbox.
  */
-const makeToolboxXML = function (isStage, targetId, categoriesXML,
+const makeToolboxXML = function (isStage, targetId, categoriesXML = [],
     costumeName = '', backdropName = '', soundName = '') {
     const gap = [categorySeparator];
 
@@ -730,21 +733,41 @@ const makeToolboxXML = function (isStage, targetId, categoriesXML,
     backdropName = xmlEscape(backdropName);
     soundName = xmlEscape(soundName);
 
+    categoriesXML = categoriesXML.slice();
+    const moveCategory = categoryId => {
+        const index = categoriesXML.findIndex(categoryInfo => categoryInfo.id === categoryId);
+        if (index >= 0) {
+            // remove the category from categoriesXML and return its XML
+            const [categoryInfo] = categoriesXML.splice(index, 1);
+            return categoryInfo.xml;
+        }
+        // return `undefined`
+    };
+    const motionXML = moveCategory('motion') || motion(isStage, targetId);
+    const looksXML = moveCategory('looks') || looks(isStage, targetId, costumeName, backdropName);
+    const soundXML = moveCategory('sound') || sound(isStage, targetId, soundName);
+    const eventsXML = moveCategory('event') || events(isStage, targetId);
+    const controlXML = moveCategory('control') || control(isStage, targetId);
+    const sensingXML = moveCategory('sensing') || sensing(isStage, targetId);
+    const operatorsXML = moveCategory('operators') || operators(isStage, targetId);
+    const variablesXML = moveCategory('data') || variables(isStage, targetId);
+    const myBlocksXML = moveCategory('procedures') || myBlocks(isStage, targetId);
+
     const everything = [
         xmlOpen,
-        motion(isStage, targetId), gap,
-        looks(isStage, targetId, costumeName, backdropName), gap,
-        sound(isStage, targetId, soundName), gap,
-        events(isStage, targetId), gap,
-        control(isStage, targetId), gap,
-        sensing(isStage, targetId), gap,
-        operators(isStage, targetId), gap,
-        variables(isStage, targetId), gap,
-        myBlocks(isStage, targetId)
+        motionXML, gap,
+        looksXML, gap,
+        soundXML, gap,
+        eventsXML, gap,
+        controlXML, gap,
+        sensingXML, gap,
+        operatorsXML, gap,
+        variablesXML, gap,
+        myBlocksXML
     ];
 
-    if (categoriesXML) {
-        everything.push(gap, categoriesXML);
+    for (const extensionCategory of categoriesXML) {
+        everything.push(gap, extensionCategory.xml);
     }
 
     everything.push(xmlClose);
diff --git a/test/unit/util/define-dynamic-block.test.js b/test/unit/util/define-dynamic-block.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..257e5529befa67b3163edfefcea230ec72c346b2
--- /dev/null
+++ b/test/unit/util/define-dynamic-block.test.js
@@ -0,0 +1,199 @@
+import defineDynamicBlock from '../../../src/lib/define-dynamic-block';
+
+import BlockType from 'scratch-vm/src/extension-support/block-type';
+
+const MockScratchBlocks = {
+    OUTPUT_SHAPE_HEXAGONAL: 1,
+    OUTPUT_SHAPE_ROUND: 2,
+    OUTPUT_SHAPE_SQUARE: 3
+};
+
+const categoryInfo = {
+    name: 'test category',
+    color1: '#111',
+    color2: '#222',
+    color3: '#333'
+};
+
+const penIconURI = 'data:image/svg+xml;base64,fake_pen_icon_svg_base64_data';
+
+const testBlockInfo = {
+    commandWithIcon: {
+        blockType: BlockType.COMMAND,
+        blockIconURI: penIconURI,
+        text: 'command with icon'
+    },
+    commandWithoutIcon: {
+        blockType: BlockType.COMMAND,
+        text: 'command without icon'
+    },
+    terminalCommand: {
+        blockType: BlockType.COMMAND,
+        isTerminal: true,
+        text: 'terminal command'
+    },
+    reporter: {
+        blockType: BlockType.REPORTER,
+        text: 'reporter'
+    },
+    boolean: {
+        blockType: BlockType.BOOLEAN,
+        text: 'Boolean'
+    },
+    hat: {
+        blockType: BlockType.HAT,
+        text: 'hat'
+    }
+};
+
+// similar to goog.mixin from the Closure library
+const mixin = function (target, source) {
+    for (const x in source) {
+        target[x] = source[x];
+    }
+};
+
+class MockBlock {
+    constructor (blockInfo, extendedOpcode) {
+        // mimic Closure-style inheritance by mixing in `defineDynamicBlock` output as this instance's prototype
+        // see also the `Blockly.Block` constructor
+        const prototype = defineDynamicBlock(MockScratchBlocks, categoryInfo, blockInfo, extendedOpcode);
+        mixin(this, prototype);
+        this.init();
+
+        // bootstrap the mutation<->DOM cycle
+        this.blockInfoText = JSON.stringify(blockInfo);
+        const xmlElement = this.mutationToDom();
+
+        // parse blockInfo from XML to fill dynamic properties
+        this.domToMutation(xmlElement);
+    }
+
+    jsonInit (json) {
+        this.result = Object.assign({}, json);
+    }
+    interpolate_ () {
+        // TODO: add tests for this?
+    }
+    setCheckboxInFlyout (isEnabled) {
+        this.result.checkboxInFlyout_ = isEnabled;
+    }
+    setOutput (isEnabled) {
+        this.result.outputConnection = isEnabled; // Blockly calls `makeConnection_` here
+    }
+    setOutputShape (outputShape) {
+        this.result.outputShape_ = outputShape;
+    }
+    setNextStatement (isEnabled) {
+        this.result.nextConnection = isEnabled; // Blockly calls `makeConnection_` here
+    }
+    setPreviousStatement (isEnabled) {
+        this.result.previousConnection = isEnabled; // Blockly calls `makeConnection_` here
+    }
+}
+
+describe('defineDynamicBlock', () => {
+    test('is a function', () => {
+        expect(typeof defineDynamicBlock).toBe('function');
+    });
+    test('can define a command block with an icon', () => {
+        const extendedOpcode = 'test.commandWithIcon';
+        const block = new MockBlock(testBlockInfo.commandWithIcon, extendedOpcode);
+        expect(block.result).toEqual({
+            category: categoryInfo.name,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3,
+            extensions: ['scratch_extension'],
+            inputsInline: true,
+            nextConnection: true,
+            outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
+            previousConnection: true,
+            type: extendedOpcode
+        });
+    });
+    test('can define a command block without an icon', () => {
+        const extendedOpcode = 'test.commandWithoutIcon';
+        const block = new MockBlock(testBlockInfo.commandWithoutIcon, extendedOpcode);
+        expect(block.result).toEqual({
+            category: categoryInfo.name,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3,
+            // extensions: undefined, // no icon means no extension
+            inputsInline: true,
+            nextConnection: true,
+            outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
+            previousConnection: true,
+            type: extendedOpcode
+        });
+    });
+    test('can define a terminal command', () => {
+        const extendedOpcode = 'test.terminal';
+        const block = new MockBlock(testBlockInfo.terminalCommand, extendedOpcode);
+        expect(block.result).toEqual({
+            category: categoryInfo.name,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3,
+            // extensions: undefined, // no icon means no extension
+            inputsInline: true,
+            nextConnection: false, // terminal
+            outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
+            previousConnection: true,
+            type: extendedOpcode
+        });
+    });
+    test('can define a reporter', () => {
+        const extendedOpcode = 'test.reporter';
+        const block = new MockBlock(testBlockInfo.reporter, extendedOpcode);
+        expect(block.result).toEqual({
+            category: categoryInfo.name,
+            checkboxInFlyout_: true,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3,
+            // extensions: undefined, // no icon means no extension
+            inputsInline: true,
+            // nextConnection: undefined, // reporter
+            outputConnection: true, // reporter
+            outputShape_: MockScratchBlocks.OUTPUT_SHAPE_ROUND, // reporter
+            // previousConnection: undefined, // reporter
+            type: extendedOpcode
+        });
+    });
+    test('can define a Boolean', () => {
+        const extendedOpcode = 'test.boolean';
+        const block = new MockBlock(testBlockInfo.boolean, extendedOpcode);
+        expect(block.result).toEqual({
+            category: categoryInfo.name,
+            // checkboxInFlyout_: undefined,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3,
+            // extensions: undefined, // no icon means no extension
+            inputsInline: true,
+            // nextConnection: undefined, // reporter
+            outputConnection: true, // reporter
+            outputShape_: MockScratchBlocks.OUTPUT_SHAPE_HEXAGONAL, // Boolean
+            // previousConnection: undefined, // reporter
+            type: extendedOpcode
+        });
+    });
+    test('can define a hat', () => {
+        const extendedOpcode = 'test.hat';
+        const block = new MockBlock(testBlockInfo.hat, extendedOpcode);
+        expect(block.result).toEqual({
+            category: categoryInfo.name,
+            colour: categoryInfo.color1,
+            colourSecondary: categoryInfo.color2,
+            colourTertiary: categoryInfo.color3,
+            // extensions: undefined, // no icon means no extension
+            inputsInline: true,
+            nextConnection: true,
+            outputShape_: MockScratchBlocks.OUTPUT_SHAPE_SQUARE,
+            // previousConnection: undefined, // hat
+            type: extendedOpcode
+        });
+    });
+});