diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8ae06284718c98c9c53ecea412f23741ad364fb7..7c19c982e044666bea121a2c42b4ade0c1b528b6 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -523,6 +523,9 @@ themeEditor: "テーマエディター"
 description: "説明"
 author: "作者"
 leaveConfirm: "未保存の変更があります。破棄しますか?"
+manage: "管理"
+plugins: "プラグイン"
+pluginInstallWarn: "信頼できないプラグインはインストールしないでください。"
 deck: "デッキ"
 undeck: "デッキ解除"
 
diff --git a/package.json b/package.json
index ad55e2e035ba20a6dbd90980977bdb75b7b5bd95..4a81ed963617e279eadacc740035ce33c1d6b892 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
 		"@koa/multer": "3.0.0",
 		"@koa/router": "9.3.1",
 		"@sinonjs/fake-timers": "6.0.1",
-		"@syuilo/aiscript": "0.7.0",
+		"@syuilo/aiscript": "0.7.2",
 		"@types/bcryptjs": "2.4.2",
 		"@types/bull": "3.14.0",
 		"@types/cbor": "5.0.0",
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index badb9f12f39adf4db79e6a95a8b8c1d7453939d9..63a803c7f42bb97e741a6361520ea47357a77318 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -89,7 +89,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
+import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
 import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
 import { parse } from '../../mfm/parse';
 import { sum, unique } from '../../prelude/array';
@@ -108,7 +108,6 @@ import { url } from '../config';
 import copyToClipboard from '../scripts/copy-to-clipboard';
 
 export default Vue.extend({
-	
 	components: {
 		XSub,
 		XNoteHeader,
@@ -145,7 +144,7 @@ export default Vue.extend({
 			showContent: false,
 			hideThisNote: false,
 			noteBody: this.$refs.noteBody,
-			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faEllipsisH
+			faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug
 		};
 	},
 
@@ -612,6 +611,16 @@ export default Vue.extend({
 				.filter(x => x !== undefined);
 			}
 
+			if (this.$store.state.noteActions.length > 0) {
+				menu = menu.concat([null, ...this.$store.state.noteActions.map(action => ({
+					icon: faPlug,
+					text: action.title,
+					action: () => {
+						action.handler(this.appearNote);
+					}
+				}))]);
+			}
+
 			this.$root.menu({
 				items: menu,
 				source: this.$refs.menuButton,
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index 392dd8c48bb95147f44f2c6d4cce9ba8ec1f78cc..f0de602c293df6f008c665d5f8dff410ff6bd3f8 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -44,6 +44,7 @@
 			<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$t('useCw')"><fa :icon="faEyeSlash"/></button>
 			<button class="_button" @click="insertMention" v-tooltip="$t('mention')"><fa :icon="faAt"/></button>
 			<button class="_button" @click="insertEmoji" v-tooltip="$t('emoji')"><fa :icon="faLaughSquint"/></button>
+			<button class="_button" @click="showActions" v-tooltip="$t('plugin')" v-if="$store.state.postFormActions.length > 0"><fa :icon="faPlug"/></button>
 		</footer>
 		<input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/>
 	</div>
@@ -52,7 +53,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard } from '@fortawesome/free-solid-svg-icons';
+import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
 import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
 import insertTextAtCursor from 'insert-text-at-cursor';
 import { length } from 'stringz';
@@ -133,7 +134,7 @@ export default Vue.extend({
 			draghover: false,
 			quoteId: null,
 			recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
-			faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard
+			faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt, faBiohazard, faPlug
 		};
 	},
 
@@ -580,6 +581,22 @@ export default Vue.extend({
 				vm.close();
 			});
 		},
+
+		showActions(ev) {
+			this.$root.menu({
+				items: this.$store.state.postFormActions.map(action => ({
+					text: action.title,
+					action: () => {
+						action.handler({
+							text: this.text
+						}, (key, value) => {
+							if (key === 'text') { this.text = value; }
+						});
+					}
+				})),
+				source: ev.currentTarget || ev.target,
+			});
+		}
 	}
 });
 </script>
diff --git a/src/client/components/user-menu.vue b/src/client/components/user-menu.vue
index 25937fb3c04427eedb806239f30cf5e89adc7fc6..cbfa7b346d8c832339cd8f877de3648c1adadc74 100644
--- a/src/client/components/user-menu.vue
+++ b/src/client/components/user-menu.vue
@@ -4,7 +4,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons';
+import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash, faPlug } from '@fortawesome/free-solid-svg-icons';
 import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons';
 import XMenu from './menu.vue';
 import copyToClipboard from '../scripts/copy-to-clipboard';
@@ -80,6 +80,16 @@ export default Vue.extend({
 			}]);
 		}
 
+		if (this.$store.state.userActions.length > 0) {
+			menu = menu.concat([null, ...this.$store.state.userActions.map(action => ({
+				icon: faPlug,
+				text: action.title,
+				action: () => {
+					action.handler(this.user);
+				}
+			}))]);
+		}
+
 		return {
 			items: menu
 		};
diff --git a/src/client/init.ts b/src/client/init.ts
index d00b4f5ccaad6a76ba0c881ee6e13388c93144da..7e11efe37ce607da65a214afd0dd47cf91d739f4 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -25,6 +25,8 @@ import { isDeviceDarkmode } from './scripts/is-device-darkmode';
 import createStore from './store';
 import { clientDb, get, count } from './db';
 import { setI18nContexts } from './scripts/set-i18n-contexts';
+import { createPluginEnv } from './scripts/aiscript/api';
+import { AiScript } from '@syuilo/aiscript';
 
 Vue.use(Vuex);
 Vue.use(VueHotkey);
@@ -231,6 +233,35 @@ os.init(async () => {
 		//store.commit('instance/set', );
 	});
 
+	for (const plugin of store.state.deviceUser.plugins) {
+		console.info('Plugin installed:', plugin.name, 'v' + plugin.version);
+
+		const aiscript = new AiScript(createPluginEnv(app, {
+			plugin: plugin,
+			storageKey: 'plugins:' + plugin.id
+		}), {
+			in: (q) => {
+				return new Promise(ok => {
+					app.dialog({
+						title: q,
+						input: {}
+					}).then(({ canceled, result: a }) => {
+						ok(a);
+					});
+				});
+			},
+			out: (value) => {
+				console.log(value);
+			},
+			log: (type, params) => {
+			},
+		});
+
+		store.commit('initPlugin', { plugin, aiscript });
+
+		aiscript.exec(plugin.ast);
+	}
+
 	if (store.getters.isSignedIn) {
 		const main = os.stream.useSharedConnection('main');
 
diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue
index 2b34513865b2f346edca2a41dc3ea055c3af764a..56c4a5699e198a2a38d2e672a082944431860007 100644
--- a/src/client/pages/preferences/index.vue
+++ b/src/client/pages/preferences/index.vue
@@ -9,6 +9,8 @@
 
 	<x-sidebar/>
 
+	<x-plugins/>
+
 	<section class="_card">
 		<div class="_title"><fa :icon="faMusic"/> {{ $t('sounds') }}</div>
 		<div class="_content">
@@ -115,6 +117,7 @@ import MkRadio from '../../components/ui/radio.vue';
 import MkRange from '../../components/ui/range.vue';
 import XTheme from './theme.vue';
 import XSidebar from './sidebar.vue';
+import XPlugins from './plugins.vue';
 import { langs } from '../../config';
 import { clientDb, set } from '../../db';
 
@@ -146,11 +149,12 @@ export default Vue.extend({
 	components: {
 		XTheme,
 		XSidebar,
+		XPlugins,
 		MkButton,
 		MkSwitch,
 		MkSelect,
 		MkRadio,
-		MkRange
+		MkRange,
 	},
 
 	data() {
diff --git a/src/client/pages/preferences/plugins.vue b/src/client/pages/preferences/plugins.vue
new file mode 100644
index 0000000000000000000000000000000000000000..afe7c8cafa206e461e2bf638a33e4fbe0a6aedf2
--- /dev/null
+++ b/src/client/pages/preferences/plugins.vue
@@ -0,0 +1,134 @@
+<template>
+<section class="_card">
+	<div class="_title"><fa :icon="faPlug"/> {{ $t('plugins') }}</div>
+	<div class="_content">
+		<details>
+			<summary><fa :icon="faDownload"/> {{ $t('install') }}</summary>
+			<mk-info warn>{{ $t('pluginInstallWarn') }}</mk-info>
+			<mk-textarea v-model="script" tall>
+				<span>{{ $t('script') }}</span>
+			</mk-textarea>
+			<mk-button @click="install()" primary><fa :icon="faSave"/> {{ $t('install') }}</mk-button>
+		</details>
+	</div>
+	<div class="_content">
+		<details>
+			<summary><fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary>
+			<mk-select v-model="selectedPluginId">
+				<option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option>
+			</mk-select>
+			<template v-if="selectedPlugin">
+				<div class="_keyValue">
+					<div>{{ $t('version') }}:</div>
+					<div>{{ selectedPlugin.version }}</div>
+				</div>
+				<div class="_keyValue">
+					<div>{{ $t('author') }}:</div>
+					<div>{{ selectedPlugin.author }}</div>
+				</div>
+				<div class="_keyValue">
+					<div>{{ $t('description') }}:</div>
+					<div>{{ selectedPlugin.description }}</div>
+				</div>
+				<mk-button @click="uninstall()" style="margin-top: 8px;"><fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</mk-button>
+			</template>
+		</details>
+	</div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkInfo from '../../components/ui/info.vue';
+import { AiScript, parse } from '@syuilo/aiscript';
+
+export default Vue.extend({
+	components: {
+		MkButton,
+		MkTextarea,
+		MkSelect,
+		MkInfo,
+	},
+	
+	data() {
+		return {
+			script: '',
+			selectedPluginId: null,
+			faPlug, faSave, faTrashAlt, faFolderOpen, faDownload
+		}
+	},
+
+	computed: {
+		selectedPlugin() {
+			if (this.selectedPluginId == null) return null;
+			return this.$store.state.deviceUser.plugins.find(x => x.id === this.selectedPluginId);
+		},
+	},
+
+	methods: {
+		install() {
+			let ast;
+			try {
+				ast = parse(this.script);
+			} catch (e) {
+				this.$root.dialog({
+					type: 'error',
+					text: 'Syntax error :('
+				});
+				return;
+			}
+			const meta = AiScript.collectMetadata(ast);
+			console.log(meta);
+			if (meta == null) {
+				this.$root.dialog({
+					type: 'error',
+					text: 'No metadata found :('
+				});
+				return;
+			}
+			const data = meta.get(null);
+			if (data == null) {
+				this.$root.dialog({
+					type: 'error',
+					text: 'No metadata found :('
+				});
+				return;
+			}
+			const { id, name, version, author, description } = data;
+			if (id == null || name == null || version == null || author == null) {
+				this.$root.dialog({
+					type: 'error',
+					text: 'Required property not found :('
+				});
+				return;
+			}
+			this.$store.commit('deviceUser/installPlugin', {
+				meta: {
+					id, name, version, author, description
+				},
+				ast
+			});
+			this.$root.dialog({
+				type: 'success',
+				iconOnly: true, autoClose: true
+			});
+		},
+
+		uninstall() {
+			this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId);
+			this.$root.dialog({
+				type: 'success',
+				iconOnly: true, autoClose: true
+			});
+		}
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/pages/scratchpad.vue b/src/client/pages/scratchpad.vue
index 81d4e6045993a1e33c44220988141246185b4d82..025505295bb18759a771257aa51e7e970ecbd0ab 100644
--- a/src/client/pages/scratchpad.vue
+++ b/src/client/pages/scratchpad.vue
@@ -30,7 +30,7 @@ import PrismEditor from 'vue-prism-editor';
 import { AiScript, parse, utils, values } from '@syuilo/aiscript';
 import MkContainer from '../components/ui/container.vue';
 import MkButton from '../components/ui/button.vue';
-import { createAiScriptEnv } from '../scripts/create-aiscript-env';
+import { createAiScriptEnv } from '../scripts/aiscript/api';
 
 export default Vue.extend({
 	metaInfo() {
diff --git a/src/client/scripts/create-aiscript-env.ts b/src/client/scripts/aiscript/api.ts
similarity index 71%
rename from src/client/scripts/create-aiscript-env.ts
rename to src/client/scripts/aiscript/api.ts
index dfa38be385593ddde59eaf8647c8419e79208e79..29baa25b1a43469672c5d654eebf7ec3dc278290 100644
--- a/src/client/scripts/create-aiscript-env.ts
+++ b/src/client/scripts/aiscript/api.ts
@@ -40,3 +40,18 @@ export function createAiScriptEnv(vm, opts) {
 		}),
 	};
 }
+
+export function createPluginEnv(vm, opts) {
+	return {
+		...createAiScriptEnv(vm, opts),
+		'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => {
+			vm.$store.commit('registerPostFormAction', { pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => {
+			vm.$store.commit('registerUserAction', { pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+		'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => {
+			vm.$store.commit('registerNoteAction', { pluginId: opts.plugin.id, title: title.value, handler });
+		}),
+	};
+}
diff --git a/src/client/scripts/hpml/evaluator.ts b/src/client/scripts/hpml/evaluator.ts
index f1fcdde0e5adcc09b79c4aa5480469260abe57b9..a056884368bb5adfa53cea289063246d28d85a59 100644
--- a/src/client/scripts/hpml/evaluator.ts
+++ b/src/client/scripts/hpml/evaluator.ts
@@ -3,7 +3,7 @@ import * as seedrandom from 'seedrandom';
 import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.';
 import { version } from '../../config';
 import { AiScript, utils, values } from '@syuilo/aiscript';
-import { createAiScriptEnv } from '../create-aiscript-env';
+import { createAiScriptEnv } from '../aiscript/api';
 import { collectPageVars } from '../collect-page-vars';
 import { initLib } from './lib';
 
diff --git a/src/client/store.ts b/src/client/store.ts
index eaa8ea6a69349da2667a98df81373b9f9ce00b62..31febc782b8148728a70cd675d3da2c18836f11d 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -3,6 +3,7 @@ import createPersistedState from 'vuex-persistedstate';
 import * as nestedProperty from 'nested-property';
 import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
 import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
+import { AiScript, utils, values } from '@syuilo/aiscript';
 import { apiUrl, deckmode } from './config';
 import { erase } from '../prelude/array';
 
@@ -43,6 +44,7 @@ export const defaultDeviceUserSettings = {
 		columns: [],
 		layout: [],
 	},
+	plugins: [],
 };
 
 export const defaultDeviceSettings = {
@@ -93,7 +95,13 @@ export default () => new Vuex.Store({
 	state: {
 		i: null,
 		pendingApiRequestsCount: 0,
-		spinner: null
+		spinner: null,
+
+		// Plugin
+		pluginContexts: new Map<string, AiScript>(),
+		postFormActions: [],
+		userActions: [],
+		noteActions: [],
 	},
 
 	getters: {
@@ -224,8 +232,38 @@ export default () => new Vuex.Store({
 			state.i = x;
 		},
 
-		updateIKeyValue(state, x) {
-			state.i[x.key] = x.value;
+		updateIKeyValue(state, { key, value }) {
+			state.i[key] = value;
+		},
+
+		initPlugin(state, { plugin, aiscript }) {
+			state.pluginContexts.set(plugin.id, aiscript);
+		},
+
+		registerPostFormAction(state, { pluginId, title, handler }) {
+			state.postFormActions.push({
+				title, handler: (form, update) => {
+					state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => {
+						update(key.value, value.value);
+					})]);
+				}
+			});
+		},
+
+		registerUserAction(state, { pluginId, title, handler }) {
+			state.userActions.push({
+				title, handler: (user) => {
+					state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]);
+				}
+			});
+		},
+
+		registerNoteAction(state, { pluginId, title, handler }) {
+			state.noteActions.push({
+				title, handler: (note) => {
+					state.pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]);
+				}
+			});
 		},
 	},
 
@@ -546,6 +584,21 @@ export default () => new Vuex.Store({
 					column = x;
 				},
 				//#endregion
+
+				installPlugin(state, { meta, ast }) {
+					state.plugins.push({
+						id: meta.id,
+						name: meta.name,
+						version: meta.version,
+						author: meta.author,
+						description: meta.description,
+						ast: ast
+					});
+				},
+
+				uninstallPlugin(state, id) {
+					state.plugins = state.plugins.filter(x => x.id != id);
+				},
 			}
 		},
 
diff --git a/yarn.lock b/yarn.lock
index 2fd02e8dbccc50e42d97bad53f2df103ef386035..ad6cb9ca37a58446b035bf309f4450d1151b60fa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -197,10 +197,10 @@
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@syuilo/aiscript@0.7.0":
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.7.0.tgz#1394511a789891e844d32e536a203fe0d92b3039"
-  integrity sha512-X4TaP/FO7RD8MpFSPDFwKAI4KX7byn8ApqmSSmf2bxcwCTcdevsbyxsLrvkbNaWclIoqTgXwtJjY+2Tc2exeXA==
+"@syuilo/aiscript@0.7.2":
+  version "0.7.2"
+  resolved "https://registry.yarnpkg.com/@syuilo/aiscript/-/aiscript-0.7.2.tgz#2f30adb14ffa9f1180af83c059927ab306b175a5"
+  integrity sha512-l8HVTJTq9KLzDqGswOIGlBepkacudUp70EScrLjL7nEL2NKcti7Ui5fwZCrmxazxgGz6NrVNX5UBIOFFyrwr0A==
   dependencies:
     autobind-decorator "2.4.0"
     chalk "4.0.0"