diff --git a/CHANGELOG.md b/CHANGELOG.md
index f065ed307f85e807a40fae0b0912c427df39f866..10de8b5fc57aef48d826745105c7a22bfab81910 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@
   (Cherry-picked from https://github.com/TeamNijimiss/misskey/commit/800359623e41a662551d774de15b0437b6849bb4)
 - Fix: ノート作成画面でファイルの添付可能個数を超えてもノートボタンが押せていた問題を修正
 - Fix: 「アカウントを管理」画面で、ユーザー情報の取得に失敗したアカウント(削除されたアカウントなど)が表示されない問題を修正
+- Enhance: AiScriptの拡張API関数において引数の型チェックをより厳格に
 
 ### Server
 - Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように
diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts
index 8afe88eec6d37aa35bd36c859eb9d7a4de3ab3f3..e203c51bba626ce52061b337a3f82c299f370523 100644
--- a/packages/frontend/src/scripts/aiscript/api.ts
+++ b/packages/frontend/src/scripts/aiscript/api.ts
@@ -3,14 +3,24 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-import { utils, values } from '@syuilo/aiscript';
+import { errors, utils, values } from '@syuilo/aiscript';
 import * as Misskey from 'misskey-js';
+import { url, lang } from '@@/js/config.js';
+import { assertStringAndIsIn } from './common.js';
 import * as os from '@/os.js';
 import { misskeyApi } from '@/scripts/misskey-api.js';
 import { $i } from '@/account.js';
 import { miLocalStorage } from '@/local-storage.js';
 import { customEmojis } from '@/custom-emojis.js';
-import { url, lang } from '@@/js/config.js';
+
+const DIALOG_TYPES = [
+	'error',
+	'info',
+	'success',
+	'warning',
+	'waiting',
+	'question',
+] as const;
 
 export function aiScriptReadline(q: string): Promise<string> {
 	return new Promise(ok => {
@@ -22,15 +32,20 @@ export function aiScriptReadline(q: string): Promise<string> {
 	});
 }
 
-export function createAiScriptEnv(opts) {
+export function createAiScriptEnv(opts: { storageKey: string, token?: string }) {
 	return {
 		USER_ID: $i ? values.STR($i.id) : values.NULL,
-		USER_NAME: $i ? values.STR($i.name) : values.NULL,
+		USER_NAME: $i?.name ? values.STR($i.name) : values.NULL,
 		USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
 		CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
 		LOCALE: values.STR(lang),
 		SERVER_URL: values.STR(url),
 		'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
+			utils.assertString(title);
+			utils.assertString(text);
+			if (type != null) {
+				assertStringAndIsIn(type, DIALOG_TYPES);
+			}
 			await os.alert({
 				type: type ? type.value : 'info',
 				title: title.value,
@@ -39,6 +54,11 @@ export function createAiScriptEnv(opts) {
 			return values.NULL;
 		}),
 		'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
+			utils.assertString(title);
+			utils.assertString(text);
+			if (type != null) {
+				assertStringAndIsIn(type, DIALOG_TYPES);
+			}
 			const confirm = await os.confirm({
 				type: type ? type.value : 'question',
 				title: title.value,
@@ -48,14 +68,20 @@ export function createAiScriptEnv(opts) {
 		}),
 		'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
 			utils.assertString(ep);
-			if (ep.value.includes('://')) throw new Error('invalid endpoint');
+			if (ep.value.includes('://')) {
+				throw new errors.AiScriptRuntimeError('invalid endpoint');
+			}
 			if (token) {
 				utils.assertString(token);
 				// バグがあればundefinedもあり得るため念のため
 				if (typeof token.value !== 'string') throw new Error('invalid token');
 			}
 			const actualToken: string|null = token?.value ?? opts.token ?? null;
-			return misskeyApi(ep.value, utils.valToJs(param), actualToken).then(res => {
+			if (param == null) {
+				throw new errors.AiScriptRuntimeError('expected param');
+			}
+			utils.assertObject(param);
+			return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => {
 				return utils.jsToVal(res);
 			}, err => {
 				return values.ERROR('request_failed', utils.jsToVal(err));
diff --git a/packages/frontend/src/scripts/aiscript/common.ts b/packages/frontend/src/scripts/aiscript/common.ts
new file mode 100644
index 0000000000000000000000000000000000000000..de6fa1d633613745f60ad146baa786dce82e5459
--- /dev/null
+++ b/packages/frontend/src/scripts/aiscript/common.ts
@@ -0,0 +1,15 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { errors, utils, type values } from '@syuilo/aiscript';
+
+export function assertStringAndIsIn<A extends readonly string[]>(value: values.Value | undefined, expects: A): asserts value is values.VStr & { value: A[number] } {
+	utils.assertString(value);
+	const str = value.value;
+	if (!expects.includes(str)) {
+		const expected = expects.map((expect) => `"${expect}"`).join(', ');
+		throw new errors.AiScriptRuntimeError(`"${value.value}" is not in ${expected}`);
+	}
+}
diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts
index 2b386bebb81aa1f79b390b7157768aaeb8e49f72..ca92b27ff54e4a3ca8869c100a53693d65ac606c 100644
--- a/packages/frontend/src/scripts/aiscript/ui.ts
+++ b/packages/frontend/src/scripts/aiscript/ui.ts
@@ -7,6 +7,15 @@ import { utils, values } from '@syuilo/aiscript';
 import { v4 as uuid } from 'uuid';
 import { ref, Ref } from 'vue';
 import * as Misskey from 'misskey-js';
+import { assertStringAndIsIn } from './common.js';
+
+const ALIGNS = ['left', 'center', 'right'] as const;
+const FONTS = ['serif', 'sans-serif', 'monospace'] as const;
+const BORDER_STYLES = ['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'] as const;
+
+type Align = (typeof ALIGNS)[number];
+type Font = (typeof FONTS)[number];
+type BorderStyle = (typeof BORDER_STYLES)[number];
 
 export type AsUiComponentBase = {
 	id: string;
@@ -21,13 +30,13 @@ export type AsUiRoot = AsUiComponentBase & {
 export type AsUiContainer = AsUiComponentBase & {
 	type: 'container';
 	children?: AsUiComponent['id'][];
-	align?: 'left' | 'center' | 'right';
+	align?: Align;
 	bgColor?: string;
 	fgColor?: string;
-	font?: 'serif' | 'sans-serif' | 'monospace';
+	font?: Font;
 	borderWidth?: number;
 	borderColor?: string;
-	borderStyle?: 'hidden' | 'dotted' | 'dashed' | 'solid' | 'double' | 'groove' | 'ridge' | 'inset' | 'outset';
+	borderStyle?: BorderStyle;
 	borderRadius?: number;
 	padding?: number;
 	rounded?: boolean;
@@ -40,7 +49,7 @@ export type AsUiText = AsUiComponentBase & {
 	size?: number;
 	bold?: boolean;
 	color?: string;
-	font?: 'serif' | 'sans-serif' | 'monospace';
+	font?: Font;
 };
 
 export type AsUiMfm = AsUiComponentBase & {
@@ -49,14 +58,14 @@ export type AsUiMfm = AsUiComponentBase & {
 	size?: number;
 	bold?: boolean;
 	color?: string;
-	font?: 'serif' | 'sans-serif' | 'monospace';
-	onClickEv?: (evId: string) => void
+	font?: Font;
+	onClickEv?: (evId: string) => Promise<void>;
 };
 
 export type AsUiButton = AsUiComponentBase & {
 	type: 'button';
 	text?: string;
-	onClick?: () => void;
+	onClick?: () => Promise<void>;
 	primary?: boolean;
 	rounded?: boolean;
 	disabled?: boolean;
@@ -69,7 +78,7 @@ export type AsUiButtons = AsUiComponentBase & {
 
 export type AsUiSwitch = AsUiComponentBase & {
 	type: 'switch';
-	onChange?: (v: boolean) => void;
+	onChange?: (v: boolean) => Promise<void>;
 	default?: boolean;
 	label?: string;
 	caption?: string;
@@ -77,7 +86,7 @@ export type AsUiSwitch = AsUiComponentBase & {
 
 export type AsUiTextarea = AsUiComponentBase & {
 	type: 'textarea';
-	onInput?: (v: string) => void;
+	onInput?: (v: string) => Promise<void>;
 	default?: string;
 	label?: string;
 	caption?: string;
@@ -85,7 +94,7 @@ export type AsUiTextarea = AsUiComponentBase & {
 
 export type AsUiTextInput = AsUiComponentBase & {
 	type: 'textInput';
-	onInput?: (v: string) => void;
+	onInput?: (v: string) => Promise<void>;
 	default?: string;
 	label?: string;
 	caption?: string;
@@ -93,7 +102,7 @@ export type AsUiTextInput = AsUiComponentBase & {
 
 export type AsUiNumberInput = AsUiComponentBase & {
 	type: 'numberInput';
-	onInput?: (v: number) => void;
+	onInput?: (v: number) => Promise<void>;
 	default?: number;
 	label?: string;
 	caption?: string;
@@ -105,7 +114,7 @@ export type AsUiSelect = AsUiComponentBase & {
 		text: string;
 		value: string;
 	}[];
-	onChange?: (v: string) => void;
+	onChange?: (v: string) => Promise<void>;
 	default?: string;
 	label?: string;
 	caption?: string;
@@ -140,11 +149,15 @@ export type AsUiPostForm = AsUiComponentBase & {
 
 export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiFolder | AsUiPostFormButton | AsUiPostForm;
 
+type Options<T extends AsUiComponent> = T extends AsUiButtons
+	? Omit<T, 'id' | 'type' | 'buttons'> & { 'buttons'?: Options<AsUiButton>[] }
+	: Omit<T, 'id' | 'type'>;
+
 export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
 	// TODO
 }
 
-function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> {
+function getRootOptions(def: values.Value | undefined): Options<AsUiRoot> {
 	utils.assertObject(def);
 
 	const children = def.value.get('children');
@@ -153,30 +166,32 @@ function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 't
 	return {
 		children: children.value.map(v => {
 			utils.assertObject(v);
-			return v.value.get('id').value;
+			const id = v.value.get('id');
+			utils.assertString(id);
+			return id.value;
 		}),
 	};
 }
 
-function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> {
+function getContainerOptions(def: values.Value | undefined): Options<AsUiContainer> {
 	utils.assertObject(def);
 
 	const children = def.value.get('children');
 	if (children) utils.assertArray(children);
 	const align = def.value.get('align');
-	if (align) utils.assertString(align);
+	if (align) assertStringAndIsIn(align, ALIGNS);
 	const bgColor = def.value.get('bgColor');
 	if (bgColor) utils.assertString(bgColor);
 	const fgColor = def.value.get('fgColor');
 	if (fgColor) utils.assertString(fgColor);
 	const font = def.value.get('font');
-	if (font) utils.assertString(font);
+	if (font) assertStringAndIsIn(font, FONTS);
 	const borderWidth = def.value.get('borderWidth');
 	if (borderWidth) utils.assertNumber(borderWidth);
 	const borderColor = def.value.get('borderColor');
 	if (borderColor) utils.assertString(borderColor);
 	const borderStyle = def.value.get('borderStyle');
-	if (borderStyle) utils.assertString(borderStyle);
+	if (borderStyle) assertStringAndIsIn(borderStyle, BORDER_STYLES);
 	const borderRadius = def.value.get('borderRadius');
 	if (borderRadius) utils.assertNumber(borderRadius);
 	const padding = def.value.get('padding');
@@ -189,7 +204,9 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer,
 	return {
 		children: children ? children.value.map(v => {
 			utils.assertObject(v);
-			return v.value.get('id').value;
+			const id = v.value.get('id');
+			utils.assertString(id);
+			return id.value;
 		}) : [],
 		align: align?.value,
 		fgColor: fgColor?.value,
@@ -205,7 +222,7 @@ function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer,
 	};
 }
 
-function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> {
+function getTextOptions(def: values.Value | undefined): Options<AsUiText> {
 	utils.assertObject(def);
 
 	const text = def.value.get('text');
@@ -217,7 +234,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
 	const color = def.value.get('color');
 	if (color) utils.assertString(color);
 	const font = def.value.get('font');
-	if (font) utils.assertString(font);
+	if (font) assertStringAndIsIn(font, FONTS);
 
 	return {
 		text: text?.value,
@@ -228,7 +245,7 @@ function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 't
 	};
 }
 
-function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiMfm, 'id' | 'type'> {
+function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiMfm> {
 	utils.assertObject(def);
 
 	const text = def.value.get('text');
@@ -240,7 +257,7 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg
 	const color = def.value.get('color');
 	if (color) utils.assertString(color);
 	const font = def.value.get('font');
-	if (font) utils.assertString(font);
+	if (font) assertStringAndIsIn(font, FONTS);
 	const onClickEv = def.value.get('onClickEv');
 	if (onClickEv) utils.assertFunction(onClickEv);
 
@@ -250,13 +267,13 @@ function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, arg
 		bold: bold?.value,
 		color: color?.value,
 		font: font?.value,
-		onClickEv: (evId: string) => {
-			if (onClickEv) call(onClickEv, [values.STR(evId)]);
+		onClickEv: async (evId: string) => {
+			if (onClickEv) await call(onClickEv, [values.STR(evId)]);
 		},
 	};
 }
 
-function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> {
+function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextInput> {
 	utils.assertObject(def);
 
 	const onInput = def.value.get('onInput');
@@ -269,8 +286,8 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF
 	if (caption) utils.assertString(caption);
 
 	return {
-		onInput: (v) => {
-			if (onInput) call(onInput, [utils.jsToVal(v)]);
+		onInput: async (v) => {
+			if (onInput) await call(onInput, [utils.jsToVal(v)]);
 		},
 		default: defaultValue?.value,
 		label: label?.value,
@@ -278,7 +295,7 @@ function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VF
 	};
 }
 
-function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> {
+function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiTextarea> {
 	utils.assertObject(def);
 
 	const onInput = def.value.get('onInput');
@@ -291,8 +308,8 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn
 	if (caption) utils.assertString(caption);
 
 	return {
-		onInput: (v) => {
-			if (onInput) call(onInput, [utils.jsToVal(v)]);
+		onInput: async (v) => {
+			if (onInput) await call(onInput, [utils.jsToVal(v)]);
 		},
 		default: defaultValue?.value,
 		label: label?.value,
@@ -300,7 +317,7 @@ function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn
 	};
 }
 
-function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> {
+function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiNumberInput> {
 	utils.assertObject(def);
 
 	const onInput = def.value.get('onInput');
@@ -313,8 +330,8 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.
 	if (caption) utils.assertString(caption);
 
 	return {
-		onInput: (v) => {
-			if (onInput) call(onInput, [utils.jsToVal(v)]);
+		onInput: async (v) => {
+			if (onInput) await call(onInput, [utils.jsToVal(v)]);
 		},
 		default: defaultValue?.value,
 		label: label?.value,
@@ -322,7 +339,7 @@ function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.
 	};
 }
 
-function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> {
+function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButton> {
 	utils.assertObject(def);
 
 	const text = def.value.get('text');
@@ -338,8 +355,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn,
 
 	return {
 		text: text?.value,
-		onClick: () => {
-			if (onClick) call(onClick, []);
+		onClick: async () => {
+			if (onClick) await call(onClick, []);
 		},
 		primary: primary?.value,
 		rounded: rounded?.value,
@@ -347,7 +364,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn,
 	};
 }
 
-function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> {
+function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiButtons> {
 	utils.assertObject(def);
 
 	const buttons = def.value.get('buttons');
@@ -369,8 +386,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn,
 
 			return {
 				text: text.value,
-				onClick: () => {
-					call(onClick, []);
+				onClick: async () => {
+					await call(onClick, []);
 				},
 				primary: primary?.value,
 				rounded: rounded?.value,
@@ -380,7 +397,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn,
 	};
 }
 
-function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> {
+function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSwitch> {
 	utils.assertObject(def);
 
 	const onChange = def.value.get('onChange');
@@ -393,8 +410,8 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn,
 	if (caption) utils.assertString(caption);
 
 	return {
-		onChange: (v) => {
-			if (onChange) call(onChange, [utils.jsToVal(v)]);
+		onChange: async (v) => {
+			if (onChange) await call(onChange, [utils.jsToVal(v)]);
 		},
 		default: defaultValue?.value,
 		label: label?.value,
@@ -402,7 +419,7 @@ function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn,
 	};
 }
 
-function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> {
+function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiSelect> {
 	utils.assertObject(def);
 
 	const items = def.value.get('items');
@@ -428,8 +445,8 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn,
 				value: value ? value.value : text.value,
 			};
 		}) : [],
-		onChange: (v) => {
-			if (onChange) call(onChange, [utils.jsToVal(v)]);
+		onChange: async (v) => {
+			if (onChange) await call(onChange, [utils.jsToVal(v)]);
 		},
 		default: defaultValue?.value,
 		label: label?.value,
@@ -437,7 +454,7 @@ function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn,
 	};
 }
 
-function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id' | 'type'> {
+function getFolderOptions(def: values.Value | undefined): Options<AsUiFolder> {
 	utils.assertObject(def);
 
 	const children = def.value.get('children');
@@ -450,7 +467,9 @@ function getFolderOptions(def: values.Value | undefined): Omit<AsUiFolder, 'id'
 	return {
 		children: children ? children.value.map(v => {
 			utils.assertObject(v);
-			return v.value.get('id').value;
+			const id = v.value.get('id');
+			utils.assertString(id);
+			return id.value;
 		}) : [],
 		title: title?.value ?? '',
 		opened: opened?.value ?? true,
@@ -475,7 +494,7 @@ function getPostFormProps(form: values.VObj): PostFormPropsForAsUi {
 	};
 }
 
-function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
+function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostFormButton> {
 	utils.assertObject(def);
 
 	const text = def.value.get('text');
@@ -497,7 +516,7 @@ function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: valu
 	};
 }
 
-function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostForm, 'id' | 'type'> {
+function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Options<AsUiPostForm> {
 	utils.assertObject(def);
 
 	const form = def.value.get('form');
@@ -511,18 +530,26 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn
 }
 
 export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
+	type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
+
 	const instances = {};
 
-	function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
+	function createComponentInstance<T extends AsUiComponent, C>(
+		type: T['type'],
+		def: values.Value | undefined,
+		id: values.Value | undefined,
+		getOptions: OptionsConverter<T, C>,
+		call: C,
+	) {
 		if (id) utils.assertString(id);
 		const _id = id?.value ?? uuid();
 		const component = ref({
 			...getOptions(def, call),
 			type,
 			id: _id,
-		});
+		} as T);
 		components.push(component);
-		const instance = values.OBJ(new Map([
+		const instance = values.OBJ(new Map<string, values.Value>([
 			['id', values.STR(_id)],
 			['update', values.FN_NATIVE(([def], opts) => {
 				utils.assertObject(def);
@@ -547,7 +574,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
 		'Ui:patch': values.FN_NATIVE(([id, val], opts) => {
 			utils.assertString(id);
 			utils.assertArray(val);
-			patch(id.value, val.value, opts.call);
+			// patch(id.value, val.value, opts.call); // TODO
 		}),
 
 		'Ui:get': values.FN_NATIVE(([id], opts) => {
@@ -566,7 +593,9 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
 
 			rootComponent.value.children = children.value.map(v => {
 				utils.assertObject(v);
-				return v.value.get('id').value;
+				const id = v.value.get('id');
+				utils.assertString(id);
+				return id.value;
 			});
 		}),
 
diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts
index e7a92e2d5cdaca7443e6748ffa8513efa16347ab..dc07ad477b4ab1c71e6ed2e8a8717be408108ec2 100644
--- a/packages/frontend/src/scripts/misskey-api.ts
+++ b/packages/frontend/src/scripts/misskey-api.ts
@@ -9,12 +9,24 @@ import { apiUrl } from '@@/js/config.js';
 import { $i } from '@/account.js';
 export const pendingApiRequestsCount = ref(0);
 
+export type Endpoint = keyof Misskey.Endpoints;
+
+export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req'];
+
+export type AnyRequest<E extends Endpoint | (string & unknown)> =
+	(E extends Endpoint ? Request<E> : never) | object;
+
+export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> =
+	E extends Endpoint
+	? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never
+	: object;
+
 // Implements Misskey.api.ApiClient.request
 export function misskeyApi<
 	ResT = void,
-	E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints,
-	P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'],
-	_ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT,
+	E extends Endpoint | NonNullable<string> = Endpoint,
+	P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never,
+	_ResT = ResT extends void ? Response<E, P> : ResT,
 >(
 	endpoint: E,
 	data: P & { i?: string | null; } = {} as any,
diff --git a/packages/frontend/test/aiscript/api.test.ts b/packages/frontend/test/aiscript/api.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2a15a74249a41e8f34eecf3fd3ef65234cafa243
--- /dev/null
+++ b/packages/frontend/test/aiscript/api.test.ts
@@ -0,0 +1,401 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { miLocalStorage } from '@/local-storage.js';
+import { aiScriptReadline, createAiScriptEnv } from '@/scripts/aiscript/api.js';
+import { errors, Interpreter, Parser, values } from '@syuilo/aiscript';
+import {
+	afterAll,
+	afterEach,
+	beforeAll,
+	beforeEach,
+	describe,
+	expect,
+	test,
+	vi
+} from 'vitest';
+
+async function exe(script: string): Promise<values.Value[]> {
+	const outputs: values.Value[] = [];
+	const interpreter = new Interpreter(
+		createAiScriptEnv({ storageKey: 'widget' }),
+		{
+			in: aiScriptReadline,
+			out: (value) => {
+				outputs.push(value);
+			}
+		}
+	);
+	const ast = Parser.parse(script);
+	await interpreter.exec(ast);
+	return outputs;
+}
+
+let $iMock = vi.hoisted<Partial<typeof import('@/account.js').$i> | null >(
+	() => null
+);
+
+vi.mock('@/account.js', () => {
+	return {
+		get $i() {
+			return $iMock;
+		},
+	};
+});
+
+const osMock = vi.hoisted(() => {
+	return {
+		inputText: vi.fn(),
+		alert: vi.fn(),
+		confirm: vi.fn(),
+	};
+});
+
+vi.mock('@/os.js', () => {
+	return osMock;
+});
+
+const misskeyApiMock = vi.hoisted(() => vi.fn());
+
+vi.mock('@/scripts/misskey-api.js', () => {
+	return { misskeyApi: misskeyApiMock };
+});
+
+describe('AiScript common API', () => {
+	afterAll(() => {
+		vi.unstubAllGlobals();
+	});
+
+	describe('readline', () => {
+		afterEach(() => {
+			vi.restoreAllMocks();
+		});
+
+		test.sequential('ok', async () => {
+			osMock.inputText.mockImplementationOnce(async ({ title }) => {
+				expect(title).toBe('question');
+				return {
+					canceled: false,
+					result: 'Hello',
+				};
+			});
+			const [res] = await exe(`
+				<: readline('question')
+			`);
+			expect(res).toStrictEqual(values.STR('Hello'));
+			expect(osMock.inputText).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('cancelled', async () => {
+			osMock.inputText.mockImplementationOnce(async ({ title }) => {
+				expect(title).toBe('question');
+				return {
+					canceled: true,
+					result: undefined,
+				};
+			});
+			const [res] = await exe(`
+				<: readline('question')
+			`);
+			expect(res).toStrictEqual(values.STR(''));
+			expect(osMock.inputText).toHaveBeenCalledOnce();
+		});
+	});
+
+	describe('user constants', () => {
+		describe.sequential('logged in', () => {
+			beforeAll(() => {
+				$iMock = {
+					id: 'xxxxxxxx',
+					name: '藍',
+					username: 'ai',
+				};
+			});
+
+			test.concurrent('USER_ID', async () => {
+				const [res] = await exe(`
+					<: USER_ID
+				`);
+				expect(res).toStrictEqual(values.STR('xxxxxxxx'));
+			});
+
+			test.concurrent('USER_NAME', async () => {
+				const [res] = await exe(`
+					<: USER_NAME
+				`);
+				expect(res).toStrictEqual(values.STR('藍'));
+			});
+
+			test.concurrent('USER_USERNAME', async () => {
+				const [res] = await exe(`
+					<: USER_USERNAME
+				`);
+				expect(res).toStrictEqual(values.STR('ai'));
+			});
+		});
+
+		describe.sequential('not logged in', () => {
+			beforeAll(() => {
+				$iMock = null;
+			});
+
+			test.concurrent('USER_ID', async () => {
+				const [res] = await exe(`
+					<: USER_ID
+				`);
+				expect(res).toStrictEqual(values.NULL);
+			});
+
+			test.concurrent('USER_NAME', async () => {
+				const [res] = await exe(`
+					<: USER_NAME
+				`);
+				expect(res).toStrictEqual(values.NULL);
+			});
+
+			test.concurrent('USER_USERNAME', async () => {
+				const [res] = await exe(`
+					<: USER_USERNAME
+				`);
+				expect(res).toStrictEqual(values.NULL);
+			});
+		});
+	});
+
+	describe('dialog', () => {
+		afterEach(() => {
+			vi.restoreAllMocks();
+		});
+
+		test.sequential('ok', async () => {
+			osMock.alert.mockImplementationOnce(async ({ type, title, text }) => {
+					expect(type).toBe('success');
+					expect(title).toBe('Hello');
+					expect(text).toBe('world');
+				});
+			const [res] = await exe(`
+				<: Mk:dialog('Hello', 'world', 'success')
+			`);
+			expect(res).toStrictEqual(values.NULL);
+			expect(osMock.alert).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('omit type', async () => {
+			osMock.alert.mockImplementationOnce(async ({ type, title, text }) => {
+					expect(type).toBe('info');
+					expect(title).toBe('Hello');
+					expect(text).toBe('world');
+				});
+			const [res] = await exe(`
+				<: Mk:dialog('Hello', 'world')
+			`);
+			expect(res).toStrictEqual(values.NULL);
+			expect(osMock.alert).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('invalid type', async () => {
+			await expect(() => exe(`
+				<: Mk:dialog('Hello', 'world', 'invalid')
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+			expect(osMock.alert).not.toHaveBeenCalled();
+		});
+	});
+
+	describe('confirm', () => {
+		afterEach(() => {
+			vi.restoreAllMocks();
+		});
+
+		test.sequential('ok', async () => {
+			osMock.confirm.mockImplementationOnce(async ({ type, title, text }) => {
+					expect(type).toBe('success');
+					expect(title).toBe('Hello');
+					expect(text).toBe('world');
+					return { canceled: false };
+				});
+			const [res] = await exe(`
+				<: Mk:confirm('Hello', 'world', 'success')
+			`);
+			expect(res).toStrictEqual(values.TRUE);
+			expect(osMock.confirm).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('omit type', async () => {
+			osMock.confirm
+				.mockImplementationOnce(async ({ type, title, text }) => {
+					expect(type).toBe('question');
+					expect(title).toBe('Hello');
+					expect(text).toBe('world');
+					return { canceled: false };
+				});
+			const [res] = await exe(`
+				<: Mk:confirm('Hello', 'world')
+			`);
+			expect(res).toStrictEqual(values.TRUE);
+			expect(osMock.confirm).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('canceled', async () => {
+			osMock.confirm.mockImplementationOnce(async ({ type, title, text }) => {
+					expect(type).toBe('question');
+					expect(title).toBe('Hello');
+					expect(text).toBe('world');
+					return { canceled: true };
+				});
+			const [res] = await exe(`
+				<: Mk:confirm('Hello', 'world')
+			`);
+			expect(res).toStrictEqual(values.FALSE);
+			expect(osMock.confirm).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('invalid type', async () => {
+			const confirm = osMock.confirm;
+			await expect(() => exe(`
+				<: Mk:confirm('Hello', 'world', 'invalid')
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+			expect(confirm).not.toHaveBeenCalled();
+		});
+	});
+
+	describe('api', () => {
+		afterEach(() => {
+			vi.restoreAllMocks();
+		});
+
+		test.sequential('successful', async () => {
+			misskeyApiMock.mockImplementationOnce(
+				async (endpoint, data, token) => {
+					expect(endpoint).toBe('ping');
+					expect(data).toStrictEqual({});
+					expect(token).toBeNull();
+					return { pong: 1735657200000 };
+				}
+			);
+			const [res] = await exe(`
+				<: Mk:api('ping', {})
+			`);
+			expect(res).toStrictEqual(values.OBJ(new Map([
+				['pong', values.NUM(1735657200000)],
+			])));
+			expect(misskeyApiMock).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('with token', async () => {
+			misskeyApiMock.mockImplementationOnce(
+				async (endpoint, data, token) => {
+					expect(endpoint).toBe('ping');
+					expect(data).toStrictEqual({});
+					expect(token).toStrictEqual('xxxxxxxx');
+					return { pong: 1735657200000 };
+				}
+			);
+			const [res] = await exe(`
+				<: Mk:api('ping', {}, 'xxxxxxxx')
+			`);
+			expect(res).toStrictEqual(values.OBJ(new Map([
+				['pong', values.NUM(1735657200000 )],
+			])));
+			expect(misskeyApiMock).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('request failed', async () => {
+			misskeyApiMock.mockRejectedValueOnce('Not Found');
+			const [res] = await exe(`
+				<: Mk:api('this/endpoint/should/not/be/found', {})
+			`);
+			expect(res).toStrictEqual(
+				values.ERROR('request_failed', values.STR('Not Found'))
+			);
+			expect(misskeyApiMock).toHaveBeenCalledOnce();
+		});
+
+		test.sequential('invalid endpoint', async () => {
+			await expect(() => exe(`
+				Mk:api('https://example.com/api/ping', {})
+			`)).rejects.toStrictEqual(
+				new errors.AiScriptRuntimeError('invalid endpoint'),
+			);
+			expect(misskeyApiMock).not.toHaveBeenCalled();
+		});
+
+		test.sequential('missing param', async () => {
+			await expect(() => exe(`
+				Mk:api('ping')
+			`)).rejects.toStrictEqual(
+				new errors.AiScriptRuntimeError('expected param'),
+			);
+			expect(misskeyApiMock).not.toHaveBeenCalled();
+		});
+	});
+
+	describe('save and load', () => {
+		beforeEach(() => {
+			miLocalStorage.removeItem('aiscript:widget:key');
+		});
+
+		afterEach(() => {
+			miLocalStorage.removeItem('aiscript:widget:key');
+		});
+
+		test.sequential('successful', async () => {
+			const [res] = await exe(`
+				Mk:save('key', 'value')
+				<: Mk:load('key')
+			`);
+			expect(miLocalStorage.getItem('aiscript:widget:key')).toBe('"value"');
+			expect(res).toStrictEqual(values.STR('value'));
+		});
+
+		test.sequential('missing value to save', async () => {
+			await expect(() => exe(`
+				Mk:save('key')
+			`)).rejects.toStrictEqual(
+				new errors.AiScriptRuntimeError('Expect anything, but got nothing.'),
+			);
+		});
+
+		test.sequential('not value found to load', async () => {
+			const [res] = await exe(`
+				<: Mk:load('key')
+			`);
+			expect(res).toStrictEqual(values.NULL);
+		});
+
+		test.sequential('remove existing', async () => {
+			const res = await exe(`
+				Mk:save('key', 'value')
+				<: Mk:load('key')
+				<: Mk:remove('key')
+				<: Mk:load('key')
+			`);
+			expect(res).toStrictEqual([values.STR('value'), values.NULL, values.NULL]);
+		});
+
+		test.sequential('remove nothing', async () => {
+			const res = await exe(`
+				<: Mk:load('key')
+				<: Mk:remove('key')
+				<: Mk:load('key')
+			`);
+			expect(res).toStrictEqual([values.NULL, values.NULL, values.NULL]);
+		});
+	});
+
+	test.concurrent('url', async () => {
+		vi.stubGlobal('location', { href: 'https://example.com/' });
+		const [res] = await exe(`
+			<: Mk:url()
+		`);
+		expect(res).toStrictEqual(values.STR('https://example.com/'));
+	});
+
+	test.concurrent('nyaize', async () => {
+		const [res] = await exe(`
+			<: Mk:nyaize('な')
+		`);
+		expect(res).toStrictEqual(values.STR('にゃ'));
+	});
+});
diff --git a/packages/frontend/test/aiscript/common.test.ts b/packages/frontend/test/aiscript/common.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..acc48826eaf7188c5ac0bcf749b3dbc2cfd42fa1
--- /dev/null
+++ b/packages/frontend/test/aiscript/common.test.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { assertStringAndIsIn } from "@/scripts/aiscript/common.js";
+import { values } from "@syuilo/aiscript";
+import { describe, expect, test } from "vitest";
+
+describe('AiScript common script', () => {
+	test('assertStringAndIsIn', () => {
+		expect(
+			() => assertStringAndIsIn(values.STR('a'), ['a', 'b'])
+		).not.toThrow();
+		expect(
+			() => assertStringAndIsIn(values.STR('c'), ['a', 'b'])
+		).toThrow('"c" is not in "a", "b"');
+		expect(() => assertStringAndIsIn(
+			values.STR('invalid'),
+			['left', 'center', 'right']
+		)).toThrow('"invalid" is not in "left", "center", "right"');
+	});
+});
diff --git a/packages/frontend/test/aiscript/ui.test.ts b/packages/frontend/test/aiscript/ui.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5f77edbb49737810c30c799e127822b7f9a73840
--- /dev/null
+++ b/packages/frontend/test/aiscript/ui.test.ts
@@ -0,0 +1,825 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { registerAsUiLib } from '@/scripts/aiscript/ui.js';
+import { errors, Interpreter, Parser, values } from '@syuilo/aiscript';
+import { describe, expect, test } from 'vitest';
+import { type Ref, ref } from 'vue';
+import type {
+	AsUiButton,
+	AsUiButtons,
+	AsUiComponent,
+	AsUiMfm,
+	AsUiNumberInput,
+	AsUiRoot,
+	AsUiSelect,
+	AsUiSwitch,
+	AsUiText,
+	AsUiTextarea,
+	AsUiTextInput,
+} from '@/scripts/aiscript/ui.js';
+
+type ExeResult = {
+	root: AsUiRoot;
+	get: (id: string) => AsUiComponent;
+	outputs: values.Value[];
+}
+async function exe(script: string): Promise<ExeResult> {
+	const rootRef = ref<AsUiRoot>();
+	const componentRefs = ref<Ref<AsUiComponent>[]>([]);
+	const outputs: values.Value[] = [];
+
+	const interpreter = new Interpreter(
+		registerAsUiLib(componentRefs.value, (root) => {
+			rootRef.value = root.value;
+		}),
+		{
+			out: (value) => {
+				outputs.push(value);
+			}
+		}
+	);
+	const ast = Parser.parse(script);
+	await interpreter.exec(ast);
+
+	const root = rootRef.value;
+	if (root === undefined) {
+		expect.unreachable('root must not be undefined');
+	}
+	const components = componentRefs.value.map(
+		(componentRef) => componentRef.value,
+	);
+	expect(root).toBe(components[0]);
+	expect(root.type).toBe('root');
+	const get = (id: string) => {
+		const component = componentRefs.value.find(
+			(componentRef) => componentRef.value.id === id,
+		);
+		if (component === undefined) {
+			expect.unreachable(`component "${id}" is not defined`);
+		}
+		return component.value;
+	};
+	return { root, get, outputs };
+}
+
+describe('AiScript UI API', () => {
+	test.concurrent('root', async () => {
+		const { root } = await exe('');
+		expect(root.children).toStrictEqual([]);
+	});
+
+	describe('get', () => {
+		test.concurrent('some', async () => {
+			const { outputs } = await exe(`
+				Ui:C:text({}, 'id')
+				<: Ui:get('id')
+			`);
+			const output = outputs[0] as values.VObj;
+			expect(output.type).toBe('obj');
+			expect(output.value.size).toBe(2);
+			expect(output.value.get('id')).toStrictEqual(values.STR('id'));
+			expect(output.value.get('update')!.type).toBe('fn');
+		});
+
+		test.concurrent('none', async () => {
+			const { outputs } = await exe(`
+				<: Ui:get('id')
+			`);
+			expect(outputs).toStrictEqual([values.NULL]);
+		});
+	});
+
+	describe('update', () => {
+		test.concurrent('normal', async () => {
+			const { get } = await exe(`
+				let text = Ui:C:text({ text: 'a' }, 'id')
+				text.update({ text: 'b' })
+			`);
+			const text = get('id') as AsUiText;
+			expect(text.text).toBe('b');
+		});
+
+		test.concurrent('skip unknown key', async () => {
+			const { get } = await exe(`
+				let text = Ui:C:text({ text: 'a' }, 'id')
+				text.update({
+					text: 'b'
+					unknown: null
+				})
+			`);
+			const text = get('id') as AsUiText;
+			expect(text.text).toBe('b');
+			expect('unknown' in text).toBeFalsy();
+		});
+	});
+
+	describe('container', () => {
+		test.concurrent('all options', async () => {
+			const { root, get } = await exe(`
+				let text = Ui:C:text({
+					text: 'text'
+				}, 'id1')
+				let container = Ui:C:container({
+					children: [text]
+					align: 'left'
+					bgColor: '#fff'
+					fgColor: '#000'
+					font: 'sans-serif'
+					borderWidth: 1
+					borderColor: '#f00'
+					borderStyle: 'hidden'
+					borderRadius: 2
+					padding: 3
+					rounded: true
+					hidden: false
+				}, 'id2')
+				Ui:render([container])
+			`);
+			expect(root.children).toStrictEqual(['id2']);
+			expect(get('id2')).toStrictEqual({
+				type: 'container',
+				id: 'id2',
+				children: ['id1'],
+				align: 'left',
+				bgColor: '#fff',
+				fgColor: '#000',
+				font: 'sans-serif',
+				borderColor: '#f00',
+				borderWidth: 1,
+				borderStyle: 'hidden',
+				borderRadius: 2,
+				padding: 3,
+				rounded: true,
+				hidden: false,
+			});
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:container({}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'container',
+				id: 'id',
+				children: [],
+				align: undefined,
+				fgColor: undefined,
+				bgColor: undefined,
+				font: undefined,
+				borderWidth: undefined,
+				borderColor: undefined,
+				borderStyle: undefined,
+				borderRadius: undefined,
+				padding: undefined,
+				rounded: undefined,
+				hidden: undefined,
+			});
+		});
+
+		test.concurrent('invalid children', async () => {
+			await expect(() => exe(`
+				Ui:C:container({
+					children: 0
+				})
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+		});
+
+		test.concurrent('invalid align', async () => {
+			await expect(() => exe(`
+				Ui:C:container({
+					align: 'invalid'
+				})
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+		});
+
+		test.concurrent('invalid font', async () => {
+			await expect(() => exe(`
+				Ui:C:container({
+					font: 'invalid'
+				})
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+		});
+
+		test.concurrent('invalid borderStyle', async () => {
+			await expect(() => exe(`
+				Ui:C:container({
+					borderStyle: 'invalid'
+				})
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+		});
+	});
+
+	describe('text', () => {
+		test.concurrent('all options', async () => {
+			const { root, get } = await exe(`
+				let text = Ui:C:text({
+					text: 'a'
+					size: 1
+					bold: true
+					color: '#000'
+					font: 'sans-serif'
+				}, 'id')
+				Ui:render([text])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			expect(get('id')).toStrictEqual({
+				type: 'text',
+				id: 'id',
+				text: 'a',
+				size: 1,
+				bold: true,
+				color: '#000',
+				font: 'sans-serif',
+			});
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:text({}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'text',
+				id: 'id',
+				text: undefined,
+				size: undefined,
+				bold: undefined,
+				color: undefined,
+				font: undefined,
+			});
+		});
+
+		test.concurrent('invalid font', async () => {
+			await expect(() => exe(`
+				Ui:C:text({
+					font: 'invalid'
+				})
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+		});
+	});
+
+	describe('mfm', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let mfm = Ui:C:mfm({
+					text: 'text'
+					size: 1
+					bold: true
+					color: '#000'
+					font: 'sans-serif'
+					onClickEv: print
+				}, 'id')
+				Ui:render([mfm])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onClickEv, ...mfm } = get('id') as AsUiMfm;
+			expect(mfm).toStrictEqual({
+				type: 'mfm',
+				id: 'id',
+				text: 'text',
+				size: 1,
+				bold: true,
+				color: '#000',
+				font: 'sans-serif',
+			});
+			await onClickEv!('a');
+			expect(outputs).toStrictEqual([values.STR('a')]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:mfm({}, 'id')
+			`);
+			const { onClickEv, ...mfm } = get('id') as AsUiMfm;
+			expect(onClickEv).toBeTypeOf('function');
+			expect(mfm).toStrictEqual({
+				type: 'mfm',
+				id: 'id',
+				text: undefined,
+				size: undefined,
+				bold: undefined,
+				color: undefined,
+				font: undefined,
+			});
+		});
+
+		test.concurrent('invalid font', async () => {
+			await expect(() => exe(`
+				Ui:C:mfm({
+					font: 'invalid'
+				})
+			`)).rejects.toBeInstanceOf(errors.AiScriptRuntimeError);
+		});
+	});
+
+	describe('textInput', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let text_input = Ui:C:textInput({
+					onInput: print
+					default: 'a'
+					label: 'b'
+					caption: 'c'
+				}, 'id')
+				Ui:render([text_input])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onInput, ...textInput } = get('id') as AsUiTextInput;
+			expect(textInput).toStrictEqual({
+				type: 'textInput',
+				id: 'id',
+				default: 'a',
+				label: 'b',
+				caption: 'c',
+			});
+			await onInput!('d');
+			expect(outputs).toStrictEqual([values.STR('d')]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:textInput({}, 'id')
+			`);
+			const { onInput, ...textInput } = get('id') as AsUiTextInput;
+			expect(onInput).toBeTypeOf('function');
+			expect(textInput).toStrictEqual({
+				type: 'textInput',
+				id: 'id',
+				default: undefined,
+				label: undefined,
+				caption: undefined,
+			});
+		});
+	});
+
+	describe('textarea', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let textarea = Ui:C:textarea({
+					onInput: print
+					default: 'a'
+					label: 'b'
+					caption: 'c'
+				}, 'id')
+				Ui:render([textarea])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onInput, ...textarea } = get('id') as AsUiTextarea;
+			expect(textarea).toStrictEqual({
+				type: 'textarea',
+				id: 'id',
+				default: 'a',
+				label: 'b',
+				caption: 'c',
+			});
+			await onInput!('d');
+			expect(outputs).toStrictEqual([values.STR('d')]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:textarea({}, 'id')
+			`);
+			const { onInput, ...textarea } = get('id') as AsUiTextarea;
+			expect(onInput).toBeTypeOf('function');
+			expect(textarea).toStrictEqual({
+				type: 'textarea',
+				id: 'id',
+				default: undefined,
+				label: undefined,
+				caption: undefined,
+			});
+		});
+	});
+
+	describe('numberInput', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let number_input = Ui:C:numberInput({
+					onInput: print
+					default: 1
+					label: 'a'
+					caption: 'b'
+				}, 'id')
+				Ui:render([number_input])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onInput, ...numberInput } = get('id') as AsUiNumberInput;
+			expect(numberInput).toStrictEqual({
+				type: 'numberInput',
+				id: 'id',
+				default: 1,
+				label: 'a',
+				caption: 'b',
+			});
+			await onInput!(2);
+			expect(outputs).toStrictEqual([values.NUM(2)]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:numberInput({}, 'id')
+			`);
+			const { onInput, ...numberInput } = get('id') as AsUiNumberInput;
+			expect(onInput).toBeTypeOf('function');
+			expect(numberInput).toStrictEqual({
+				type: 'numberInput',
+				id: 'id',
+				default: undefined,
+				label: undefined,
+				caption: undefined,
+			});
+		});
+	});
+
+	describe('button', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let button = Ui:C:button({
+					text: 'a'
+					onClick: @() { <: 'clicked' }
+					primary: true
+					rounded: false
+					disabled: false
+				}, 'id')
+				Ui:render([button])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onClick, ...button } = get('id') as AsUiButton;
+			expect(button).toStrictEqual({
+				type: 'button',
+				id: 'id',
+				text: 'a',
+				primary: true,
+				rounded: false,
+				disabled: false,
+			});
+			await onClick!();
+			expect(outputs).toStrictEqual([values.STR('clicked')]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:button({}, 'id')
+			`);
+			const { onClick, ...button } = get('id') as AsUiButton;
+			expect(onClick).toBeTypeOf('function');
+			expect(button).toStrictEqual({
+				type: 'button',
+				id: 'id',
+				text: undefined,
+				primary: undefined,
+				rounded: undefined,
+				disabled: undefined,
+			});
+		});
+	});
+
+	describe('buttons', () => {
+		test.concurrent('all options', async () => {
+			const { root, get } = await exe(`
+				let buttons = Ui:C:buttons({
+					buttons: []
+				}, 'id')
+				Ui:render([buttons])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			expect(get('id')).toStrictEqual({
+				type: 'buttons',
+				id: 'id',
+				buttons: [],
+			});
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:buttons({}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'buttons',
+				id: 'id',
+				buttons: [],
+			});
+		});
+
+		test.concurrent('some buttons', async () => {
+			const { root, get, outputs } = await exe(`
+				let buttons = Ui:C:buttons({
+					buttons: [
+						{
+							text: 'a'
+							onClick: @() { <: 'clicked a' }
+							primary: true
+							rounded: false
+							disabled: false
+						}
+						{
+							text: 'b'
+							onClick: @() { <: 'clicked b' }
+							primary: true
+							rounded: false
+							disabled: false
+						}
+					]
+				}, 'id')
+				Ui:render([buttons])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { buttons, ...buttonsOptions } = get('id') as AsUiButtons;
+			expect(buttonsOptions).toStrictEqual({
+				type: 'buttons',
+				id: 'id',
+			});
+			expect(buttons!.length).toBe(2);
+			const { onClick: onClickA, ...buttonA } = buttons![0];
+			expect(buttonA).toStrictEqual({
+				text: 'a',
+				primary: true,
+				rounded: false,
+				disabled: false,
+			});
+			const { onClick: onClickB, ...buttonB } = buttons![1];
+			expect(buttonB).toStrictEqual({
+				text: 'b',
+				primary: true,
+				rounded: false,
+				disabled: false,
+			});
+			await onClickA!();
+			await onClickB!();
+			expect(outputs).toStrictEqual(
+				[values.STR('clicked a'), values.STR('clicked b')]
+			);
+		});
+	});
+
+	describe('switch', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let switch = Ui:C:switch({
+					onChange: print
+					default: false
+					label: 'a'
+					caption: 'b'
+				}, 'id')
+				Ui:render([switch])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onChange, ...switchOptions } = get('id') as AsUiSwitch;
+			expect(switchOptions).toStrictEqual({
+				type: 'switch',
+				id: 'id',
+				default: false,
+				label: 'a',
+				caption: 'b',
+			});
+			await onChange!(true);
+			expect(outputs).toStrictEqual([values.TRUE]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:switch({}, 'id')
+			`);
+			const { onChange, ...switchOptions } = get('id') as AsUiSwitch;
+			expect(onChange).toBeTypeOf('function');
+			expect(switchOptions).toStrictEqual({
+				type: 'switch',
+				id: 'id',
+				default: undefined,
+				label: undefined,
+				caption: undefined,
+			});
+		});
+	});
+
+	describe('select', () => {
+		test.concurrent('all options', async () => {
+			const { root, get, outputs } = await exe(`
+				let select = Ui:C:select({
+					items: [
+						{ text: 'A', value: 'a' }
+						{ text: 'B', value: 'b' }
+					]
+					onChange: print
+					default: 'a'
+					label: 'c'
+					caption: 'd'
+				}, 'id')
+				Ui:render([select])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			const { onChange, ...select } = get('id') as AsUiSelect;
+			expect(select).toStrictEqual({
+				type: 'select',
+				id: 'id',
+				items: [
+					{ text: 'A', value: 'a' },
+					{ text: 'B', value: 'b' },
+				],
+				default: 'a',
+				label: 'c',
+				caption: 'd',
+			});
+			await onChange!('b');
+			expect(outputs).toStrictEqual([values.STR('b')]);
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:select({}, 'id')
+			`);
+			const { onChange, ...select } = get('id') as AsUiSelect;
+			expect(onChange).toBeTypeOf('function');
+			expect(select).toStrictEqual({
+				type: 'select',
+				id: 'id',
+				items: [],
+				default: undefined,
+				label: undefined,
+				caption: undefined,
+			});
+		});
+
+		test.concurrent('omit item values', async () => {
+			const { get } = await exe(`
+				let select = Ui:C:select({
+					items: [
+						{ text: 'A' }
+						{ text: 'B' }
+					]
+				}, 'id')
+			`);
+			const { onChange, ...select } = get('id') as AsUiSelect;
+			expect(onChange).toBeTypeOf('function');
+			expect(select).toStrictEqual({
+				type: 'select',
+				id: 'id',
+				items: [
+					{ text: 'A', value: 'A' },
+					{ text: 'B', value: 'B' },
+				],
+				default: undefined,
+				label: undefined,
+				caption: undefined,
+			});
+		});
+	});
+
+	describe('folder', () => {
+		test.concurrent('all options', async () => {
+			const { root, get } = await exe(`
+				let folder = Ui:C:folder({
+					children: []
+					title: 'a'
+					opened: true
+				}, 'id')
+				Ui:render([folder])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			expect(get('id')).toStrictEqual({
+				type: 'folder',
+				id: 'id',
+				children: [],
+				title: 'a',
+				opened: true,
+			});
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:folder({}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'folder',
+				id: 'id',
+				children: [],
+				title: '',
+				opened: true,
+			});
+		});
+
+		test.concurrent('some children', async () => {
+			const { get } = await exe(`
+				let text = Ui:C:text({
+					text: 'text'
+				}, 'id1')
+				Ui:C:folder({
+					children: [text]
+				}, 'id2')
+			`);
+			expect(get('id2')).toStrictEqual({
+				type: 'folder',
+				id: 'id2',
+				children: ['id1'],
+				title: '',
+				opened: true,
+			});
+		});
+	});
+
+	describe('postFormButton', () => {
+		test.concurrent('all options', async () => {
+			const { root, get } = await exe(`
+				let post_form_button = Ui:C:postFormButton({
+					text: 'a'
+					primary: true
+					rounded: false
+					form: {
+						text: 'b'
+						cw: 'c'
+						visibility: 'public'
+						localOnly: true
+					}
+				}, 'id')
+				Ui:render([post_form_button])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			expect(get('id')).toStrictEqual({
+				type: 'postFormButton',
+				id: 'id',
+				text: 'a',
+				primary: true,
+				rounded: false,
+				form: {
+					text: 'b',
+					cw: 'c',
+					visibility: 'public',
+					localOnly: true,
+				},
+			});
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:postFormButton({}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'postFormButton',
+				id: 'id',
+				text: undefined,
+				primary: undefined,
+				rounded: undefined,
+				form: { text: '' },
+			});
+		});
+	});
+
+	describe('postForm', () => {
+		test.concurrent('all options', async () => {
+			const { root, get } = await exe(`
+				let post_form = Ui:C:postForm({
+					form: {
+						text: 'a'
+						cw: 'b'
+						visibility: 'public'
+						localOnly: true
+					}
+				}, 'id')
+				Ui:render([post_form])
+			`);
+			expect(root.children).toStrictEqual(['id']);
+			expect(get('id')).toStrictEqual({
+				type: 'postForm',
+				id: 'id',
+				form: {
+					text: 'a',
+					cw: 'b',
+					visibility: 'public',
+					localOnly: true,
+				},
+			});
+		});
+
+		test.concurrent('minimum options', async () => {
+			const { get } = await exe(`
+				Ui:C:postForm({}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'postForm',
+				id: 'id',
+				form: { text: '' },
+			});
+		});
+
+		test.concurrent('minimum options for form', async () => {
+			const { get } = await exe(`
+				Ui:C:postForm({
+					form: { text: '' }
+				}, 'id')
+			`);
+			expect(get('id')).toStrictEqual({
+				type: 'postForm',
+				id: 'id',
+				form: {
+					text: '',
+					cw: undefined,
+					visibility: undefined,
+					localOnly: undefined,
+				},
+			});
+		});
+	});
+});