diff --git a/frontend/src/metabase-types/api/data-app.ts b/frontend/src/metabase-types/api/data-app.ts
index f0428feb2650c2beb0b4bd15777c24d0779ff040..24e816ff1aadf35fd547a4c7a388af773f3d8e01 100644
--- a/frontend/src/metabase-types/api/data-app.ts
+++ b/frontend/src/metabase-types/api/data-app.ts
@@ -47,6 +47,7 @@ export interface ActionDashboardCard
   extends Omit<BaseDashboardOrderedCard, "parameter_mappings"> {
   action_id: number | null;
   action?: WritebackAction;
+  card_id?: number; // model card id for the associated action
 
   parameter_mappings?: ActionParametersMapping[] | null;
   visualization_settings: {
diff --git a/frontend/src/metabase-types/api/parameters.ts b/frontend/src/metabase-types/api/parameters.ts
index 03fbfb97d921e44a75e2d9f72360fbb725555f7b..78e5eda47abc366ce911ebcfd8413e88ffe66c45 100644
--- a/frontend/src/metabase-types/api/parameters.ts
+++ b/frontend/src/metabase-types/api/parameters.ts
@@ -27,3 +27,5 @@ export type ParameterType =
   | DateParameterType;
 
 export type ParameterId = string;
+
+export type ActionParameterValue = string | number;
diff --git a/frontend/src/metabase-types/api/writeback.ts b/frontend/src/metabase-types/api/writeback.ts
index f5d82977cb6e28de8f0f18373fb8167414659e14..cda2f510edaf8150db42d1266970fad8f92b1d98 100644
--- a/frontend/src/metabase-types/api/writeback.ts
+++ b/frontend/src/metabase-types/api/writeback.ts
@@ -5,10 +5,12 @@ export interface WritebackParameter extends Parameter {
   target: ParameterTarget;
 }
 
-export type WritebackActionType = "http" | "query";
+export type WritebackActionType = "http" | "query" | "implicit";
 
 export interface WritebackActionBase {
   id: number;
+  model_id?: number;
+  slug?: string;
   name: string;
   description: string | null;
   parameters: WritebackParameter[];
@@ -23,7 +25,7 @@ export type QueryActionCard = Card & {
 };
 
 export interface QueryAction {
-  type: "query";
+  type: "query" | "implicit";
   card: QueryActionCard;
   card_id: number;
 }
@@ -53,22 +55,10 @@ export type WritebackAction = WritebackActionBase & (QueryAction | HttpAction);
 
 export type ParameterMappings = Record<ParameterId, ParameterTarget>;
 
-type ParameterForActionExecutionBase = {
-  type: string;
-  value: string | number;
+export type ParametersForActionExecution = {
+  [id: ParameterId]: string | number;
 };
 
-export type ParameterMappedForActionExecution =
-  ParameterForActionExecutionBase & {
-    id: ParameterId;
-    target: ParameterTarget;
-  };
-
-export type ArbitraryParameterForActionExecution =
-  ParameterForActionExecutionBase & {
-    target: ParameterTarget;
-  };
-
 export interface ActionFormSubmitResult {
   success: boolean;
   message?: string;
@@ -76,5 +66,17 @@ export interface ActionFormSubmitResult {
 }
 
 export type OnSubmitActionForm = (
-  parameters: ArbitraryParameterForActionExecution[],
+  parameters: ParametersForActionExecution,
 ) => Promise<ActionFormSubmitResult>;
+
+export interface ModelAction {
+  id: number;
+  action_id?: number; // empty for implicit actions
+  name?: string; // empty for implicit actions
+  card_id: number; // the card id of the model
+  entity_id: string;
+  requires_pk: boolean;
+  slug: string;
+  parameter_mappings?: ParameterMappings;
+  visualization_settings?: ActionFormSettings;
+}
diff --git a/frontend/src/metabase/components/form/FormWidget.jsx b/frontend/src/metabase/components/form/FormWidget.jsx
index 033c2490e484b152ddbfb380202ba9fe4db2cafa..17da1cc9f5dcc1b701ba3c72ab404e3ea69f8807 100644
--- a/frontend/src/metabase/components/form/FormWidget.jsx
+++ b/frontend/src/metabase/components/form/FormWidget.jsx
@@ -20,6 +20,7 @@ import FormCollectionWidget from "./widgets/FormCollectionWidget";
 import FormSnippetCollectionWidget from "./widgets/FormSnippetCollectionWidget";
 import FormHiddenWidget from "./widgets/FormHiddenWidget";
 import FormTextFileWidget from "./widgets/FormTextFileWidget";
+import FormModelWidget from "./widgets/FormModelWidget";
 
 const WIDGETS = {
   info: FormInfoWidget,
@@ -39,6 +40,7 @@ const WIDGETS = {
   snippetCollection: FormSnippetCollectionWidget,
   hidden: FormHiddenWidget,
   textFile: FormTextFileWidget,
+  model: FormModelWidget,
 };
 
 export function getWidgetComponent(formField) {
diff --git a/frontend/src/metabase/components/form/widgets/FormModelWidget.tsx b/frontend/src/metabase/components/form/widgets/FormModelWidget.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..624387454a9d7868c3bf880d675adb056c3221b3
--- /dev/null
+++ b/frontend/src/metabase/components/form/widgets/FormModelWidget.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+
+import ModelPicker from "metabase/containers/ModelPicker";
+import ItemSelect from "metabase/containers/ItemSelect";
+
+import Question from "metabase/entities/questions";
+
+import type { Card } from "metabase-types/api";
+import type { FormField } from "metabase-types/forms";
+
+const ModelSelect = ItemSelect(
+  ModelPicker,
+  ({ id }: { id: Card["id"] }) => <Question.Name id={id} />,
+  "dataset",
+);
+
+function FormModelWidget({ field }: { field: FormField<string, Card["id"]> }) {
+  return <ModelSelect {...field} />;
+}
+
+export default FormModelWidget;
diff --git a/frontend/src/metabase/containers/ActionPicker/ActionOptionItem.styled.tsx b/frontend/src/metabase/containers/ActionPicker/ActionOptionItem.styled.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3cd5fcebf450a5ab8605621c6eb0fafa3a5c1b73
--- /dev/null
+++ b/frontend/src/metabase/containers/ActionPicker/ActionOptionItem.styled.tsx
@@ -0,0 +1,44 @@
+import _ from "underscore";
+import styled from "@emotion/styled";
+
+import { color, lighten } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+import Icon from "metabase/components/Icon";
+
+interface ActionOptionProps {
+  isSelected?: boolean;
+  hasDescription?: boolean;
+}
+
+export const ActionOptionListItem = styled.div<ActionOptionProps>`
+  color: ${props =>
+    props.isSelected ? color("text-white") : color("text-normal")};
+  background-color: ${props =>
+    props.isSelected ? color("brand") : color("white")};
+  cursor: pointer;
+
+  display: flex;
+  align-items: ${props => (props.hasDescription ? "flex-start" : "center")};
+  gap: ${space(1)};
+
+  border: 1px solid ${color("border")};
+  border-radius: ${space(1)};
+
+  padding: ${space(2)};
+  margin: ${space(1)} ${space(0)};
+
+  &:hover {
+    background-color: ${lighten("brand", 0.1)};
+    color: ${color("text-white")};
+  }
+`;
+
+export const ActionOptionTitle = styled.div`
+  font-size: 0.875rem;
+  font-weight: bold;
+`;
+
+export const ActionOptionDescription = styled.div`
+  font-size: 0.875rem;
+  margin-top: ${space(1)};
+`;
diff --git a/frontend/src/metabase/containers/ActionPicker/ActionOptionItem.tsx b/frontend/src/metabase/containers/ActionPicker/ActionOptionItem.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2d42a2b0718831379f09a2a9f3fe12444b4cb6d4
--- /dev/null
+++ b/frontend/src/metabase/containers/ActionPicker/ActionOptionItem.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+
+import Icon from "metabase/components/Icon";
+import {
+  ActionOptionListItem,
+  ActionOptionTitle,
+  ActionOptionDescription,
+} from "./ActionOptionItem.styled";
+
+interface ActionOptionProps {
+  name: string;
+  description?: string | null;
+  isSelected: boolean;
+  onClick: () => void;
+}
+
+export default function ActionOptionItem({
+  name,
+  description,
+  isSelected,
+  onClick,
+}: ActionOptionProps) {
+  return (
+    <ActionOptionListItem
+      onClick={onClick}
+      isSelected={isSelected}
+      hasDescription={!!description}
+    >
+      <Icon name="insight" size={22} />
+      <div>
+        <ActionOptionTitle>{name}</ActionOptionTitle>
+        {!!description && (
+          <ActionOptionDescription>{description}</ActionOptionDescription>
+        )}
+      </div>
+    </ActionOptionListItem>
+  );
+}
diff --git a/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx b/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5cf4ba6d3e4e031223f7aaab65bd5b6778fc57c5
--- /dev/null
+++ b/frontend/src/metabase/containers/ActionPicker/ActionPicker.styled.tsx
@@ -0,0 +1,34 @@
+import _ from "underscore";
+import styled from "@emotion/styled";
+
+import { color } from "metabase/lib/colors";
+
+import { SidebarItem } from "metabase/dashboard/components/ClickBehaviorSidebar/SidebarItem";
+
+export const ActionSidebarItem = styled(SidebarItem.Selectable)<{
+  hasDescription?: boolean;
+}>`
+  align-items: ${props => (props.hasDescription ? "flex-start" : "center")};
+  margin-top: 2px;
+`;
+
+export const ActionSidebarItemIcon = styled(SidebarItem.Icon)<{
+  isSelected?: boolean;
+}>`
+  .Icon {
+    color: ${props =>
+      props.isSelected ? color("text-white") : color("brand")};
+  }
+`;
+
+export const ActionDescription = styled.span<{ isSelected?: boolean }>`
+  width: 95%;
+  margin-top: 2px;
+
+  color: ${props =>
+    props.isSelected ? color("text-white") : color("text-medium")};
+`;
+
+export const ClickMappingsContainer = styled.div`
+  margin-top: 1rem;
+`;
diff --git a/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx b/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a7c28796f4c277b60806d3acd929ac0eff787880
--- /dev/null
+++ b/frontend/src/metabase/containers/ActionPicker/ActionPicker.tsx
@@ -0,0 +1,86 @@
+import React, { useState } from "react";
+import { t } from "ttag";
+
+import Button from "metabase/core/components/Button";
+import EmptyState from "metabase/components/EmptyState";
+
+import Actions from "metabase/entities/actions";
+import type { WritebackAction } from "metabase-types/api";
+import type { State } from "metabase-types/store";
+
+import ModelPicker from "../ModelPicker";
+import ActionOptionItem from "./ActionOptionItem";
+
+export default function ActionPicker({
+  value,
+  onChange,
+}: {
+  value: WritebackAction | undefined;
+  onChange: (value: WritebackAction) => void;
+}) {
+  const [modelId, setModelId] = useState<number | undefined>(value?.model_id);
+
+  return (
+    <div className="scroll-y">
+      {!modelId ? (
+        <ModelPicker value={modelId} onChange={setModelId} />
+      ) : (
+        <>
+          <Button
+            icon="arrow_left"
+            borderless
+            onClick={() => setModelId(undefined)}
+          >
+            {t`Select Model`}
+          </Button>
+
+          <ConnectedModelActionPicker
+            modelId={modelId}
+            value={value}
+            onChange={onChange}
+          />
+        </>
+      )}
+    </div>
+  );
+}
+
+function ModelActionPicker({
+  value,
+  onChange,
+  actions,
+}: {
+  value: WritebackAction | undefined;
+  onChange: (newValue: WritebackAction) => void;
+  actions: WritebackAction[];
+}) {
+  if (!actions?.length) {
+    return (
+      <EmptyState
+        message={t`There are no actions for this model`}
+        action={t`Create new action`}
+        link={"/action/create"}
+      />
+    );
+  }
+
+  return (
+    <ul>
+      {actions?.map(action => (
+        <ActionOptionItem
+          name={action.name}
+          description={action.description}
+          isSelected={action.id === value?.id}
+          key={action.slug}
+          onClick={() => onChange(action)}
+        />
+      ))}
+    </ul>
+  );
+}
+
+const ConnectedModelActionPicker = Actions.loadList({
+  query: (state: State, props: { modelId?: number | null }) => ({
+    "model-id": props?.modelId,
+  }),
+})(ModelActionPicker);
diff --git a/frontend/src/metabase/containers/ActionPicker/index.ts b/frontend/src/metabase/containers/ActionPicker/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..36962a14f13c669494001e5065d385cec07a81e9
--- /dev/null
+++ b/frontend/src/metabase/containers/ActionPicker/index.ts
@@ -0,0 +1 @@
+export { default } from "./ActionPicker";
diff --git a/frontend/src/metabase/containers/ItemSelect.jsx b/frontend/src/metabase/containers/ItemSelect.jsx
index 3e26e2be2d4a7c3ef1eeac5e6e64d2597163ef72..5dc62573f1fc874484147dd1c5cfa96f6ee71945 100644
--- a/frontend/src/metabase/containers/ItemSelect.jsx
+++ b/frontend/src/metabase/containers/ItemSelect.jsx
@@ -10,6 +10,14 @@ import SelectButton from "metabase/core/components/SelectButton";
 
 const MIN_POPOVER_WIDTH = 300;
 
+const typeNameMap = {
+  card: () => t`question`,
+  dataset: () => t`model`,
+  table: () => t`table`,
+  dashboard: () => t`dashboard`,
+  page: () => t`page`,
+};
+
 export default (PickerComponent, NameComponent, type) =>
   class ItemSelect extends React.Component {
     state = {
@@ -28,7 +36,7 @@ export default (PickerComponent, NameComponent, type) =>
     };
 
     static defaultProps = {
-      placeholder: t`Select a ${type}`,
+      placeholder: t`Select a ${typeNameMap[type]?.() ?? type}`,
       inheritWidth: true,
     };
 
diff --git a/frontend/src/metabase/containers/ModelPicker.jsx b/frontend/src/metabase/containers/ModelPicker.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..322a4aae2bddabfaf89a0f8547b3f7c93fc179e4
--- /dev/null
+++ b/frontend/src/metabase/containers/ModelPicker.jsx
@@ -0,0 +1,20 @@
+import React from "react";
+import PropTypes from "prop-types";
+
+import ItemPicker from "./ItemPicker";
+
+const ModelPicker = ({ value, onChange, ...props }) => (
+  <ItemPicker
+    {...props}
+    value={value === undefined ? undefined : { model: "page", id: value }}
+    onChange={page => onChange(page ? page.id : undefined)}
+    models={["dataset"]}
+  />
+);
+
+ModelPicker.propTypes = {
+  value: PropTypes.number,
+  onChange: PropTypes.func.isRequired,
+};
+
+export default ModelPicker;
diff --git a/frontend/src/metabase/dashboard/actions/save.js b/frontend/src/metabase/dashboard/actions/save.js
index 7253cb395a75e464545eda9b88137e7206c77e5e..f176e62fa547519409a6e907df6eb87e2412924a 100644
--- a/frontend/src/metabase/dashboard/actions/save.js
+++ b/frontend/src/metabase/dashboard/actions/save.js
@@ -113,8 +113,8 @@ export const saveDashboardAndCards = createThunkAction(
         const cards = updatedDashcards.map(
           ({
             id,
-            action_id,
             card_id,
+            action,
             row,
             col,
             size_x,
@@ -124,8 +124,8 @@ export const saveDashboardAndCards = createThunkAction(
             visualization_settings,
           }) => ({
             id,
-            action_id,
             card_id,
+            action,
             row,
             col,
             size_x,
@@ -142,6 +142,7 @@ export const saveDashboardAndCards = createThunkAction(
                   }) &&
                   // filter out mappings for deleted series
                   (!card_id ||
+                    action ||
                     card_id === mapping.card_id ||
                     _.findWhere(series, { id: mapping.card_id })),
               ),
diff --git a/frontend/src/metabase/dashboard/actions/writeback.ts b/frontend/src/metabase/dashboard/actions/writeback.ts
index cd2382b0ce507ede01869be33543a5acceee9211..f58ec5e5fe30d231268aa17c51dae993e438730a 100644
--- a/frontend/src/metabase/dashboard/actions/writeback.ts
+++ b/frontend/src/metabase/dashboard/actions/writeback.ts
@@ -22,9 +22,10 @@ import type {
   Dashboard,
   DashboardOrderedCard,
   ActionDashboardCard,
-  ParameterMappedForActionExecution,
-  ArbitraryParameterForActionExecution,
+  ParametersForActionExecution,
   ActionFormSubmitResult,
+  WritebackAction,
+  ActionParametersMapping,
 } from "metabase-types/api";
 import type { Dispatch } from "metabase-types/store";
 
@@ -44,9 +45,16 @@ export const closeActionParametersModal = createAction(
   CLOSE_ACTION_PARAMETERS_MODAL,
 );
 
+interface DashboardAttributes {
+  card_id?: number | null;
+  action?: WritebackAction | null;
+  parameter_mappings?: ActionParametersMapping[] | null;
+  visualization_settings?: ActionDashboardCard["visualization_settings"];
+}
+
 export function updateButtonActionMapping(
   dashCardId: number,
-  attributes: { action_id?: number | null; parameter_mappings?: any },
+  attributes: DashboardAttributes,
 ) {
   return (dispatch: Dispatch) => {
     dispatch(
@@ -234,8 +242,7 @@ export const deleteManyRowsFromDataApp = (
 export type ExecuteRowActionPayload = {
   dashboard: Dashboard;
   dashcard: ActionDashboardCard;
-  parameters: ParameterMappedForActionExecution[];
-  extra_parameters: ArbitraryParameterForActionExecution[];
+  parameters: ParametersForActionExecution;
   dispatch: Dispatch;
   shouldToast?: boolean;
 };
@@ -244,7 +251,6 @@ export const executeRowAction = async ({
   dashboard,
   dashcard,
   parameters,
-  extra_parameters,
   dispatch,
   shouldToast = true,
 }: ExecuteRowActionPayload): Promise<ActionFormSubmitResult> => {
@@ -253,16 +259,21 @@ export const executeRowAction = async ({
     const result = await ActionsApi.execute({
       dashboardId: dashboard.id,
       dashcardId: dashcard.id,
+      modelId: dashcard.card_id,
+      slug: dashcard.action?.slug,
       parameters,
-      extra_parameters,
     });
 
-    if (result["rows-affected"] > 0) {
-      dispatch(reloadDashboardCards());
+    if (result["rows-affected"] > 0 || result["rows-updated"]?.[0] > 0) {
       message = t`Successfully executed the action`;
+    } else if (result["created-row"]) {
+      message = t`Successfully saved`;
+    } else if (result["rows-deleted"]?.[0] > 0) {
+      message = t`Successfully deleted`;
     } else {
       message = t`Success! The action returned: ${JSON.stringify(result)}`;
     }
+    dispatch(reloadDashboardCards());
     if (shouldToast) {
       dispatch(
         addUndo({
diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptionItem.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptionItem.tsx
deleted file mode 100644
index 8330c569857bd9af91c2ea8a2d9b723479ade5c0..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptionItem.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from "react";
-
-import { SidebarItem } from "../SidebarItem";
-import {
-  ActionSidebarItem,
-  ActionSidebarItemIcon,
-  ActionDescription,
-} from "./ActionOptions.styled";
-
-interface ActionOptionProps {
-  name: string;
-  description?: string | null;
-  isSelected: boolean;
-  onClick: () => void;
-}
-
-function ActionOptionItem({
-  name,
-  description,
-  isSelected,
-  onClick,
-}: ActionOptionProps) {
-  return (
-    <ActionSidebarItem
-      onClick={onClick}
-      isSelected={isSelected}
-      hasDescription={!!description}
-    >
-      <ActionSidebarItemIcon name="insight" isSelected={isSelected} />
-      <div>
-        <SidebarItem.Name>{name}</SidebarItem.Name>
-        {description && <ActionDescription>{description}</ActionDescription>}
-      </div>
-    </ActionSidebarItem>
-  );
-}
-
-export default ActionOptionItem;
diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx
index 98a1e87300b5ebe6e2cf9a6a23b284038aae464b..d8b033260e31c68e1c1a4f7a564d928ecb97d454 100644
--- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx
+++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.styled.tsx
@@ -32,3 +32,10 @@ export const ActionDescription = styled.span<{ isSelected?: boolean }>`
 export const ClickMappingsContainer = styled.div`
   margin-top: 1rem;
 `;
+
+export const ActionPickerWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  max-height: calc(100vh - 30rem);
+  overflow-y: auto;
+`;
diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx
index d6bf5523dca1ab463430c961057e41e19979b8e7..1b28a5f9b4f7b9c356a40ecd04747fd3f5ff2333 100644
--- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx
+++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/ActionOptions.tsx
@@ -2,11 +2,11 @@ import React, { useCallback } from "react";
 import { t } from "ttag";
 import { connect } from "react-redux";
 
-import Actions from "metabase/entities/actions";
-
 import { updateButtonActionMapping } from "metabase/dashboard/actions";
 import { updateSettings } from "metabase/visualizations/lib/settings";
 
+import ActionPicker from "metabase/containers/ActionPicker";
+
 import type {
   ActionDashboardCard,
   ActionParametersMapping,
@@ -18,8 +18,12 @@ import type { UiParameter } from "metabase/parameters/types";
 import { Heading, SidebarContent } from "../ClickBehaviorSidebar.styled";
 
 import ActionClickMappings from "./ActionClickMappings";
-import ActionOptionItem from "./ActionOptionItem";
-import { ClickMappingsContainer } from "./ActionOptions.styled";
+import {
+  ClickMappingsContainer,
+  ActionPickerWrapper,
+} from "./ActionOptions.styled";
+
+import { ensureParamsHaveNames } from "./utils";
 
 interface ActionOptionsOwnProps {
   dashcard: ActionDashboardCard;
@@ -30,10 +34,10 @@ interface ActionOptionsDispatchProps {
   onUpdateButtonActionMapping: (
     dashCardId: number,
     settings: {
-      action_id?: number | null;
-      action?: WritebackAction;
-      visualization_settings?: ActionDashboardCard["visualization_settings"];
+      card_id?: number | null;
+      action?: WritebackAction | null;
       parameter_mappings?: ActionParametersMapping[] | null;
+      visualization_settings?: ActionDashboardCard["visualization_settings"];
     },
   ) => void;
 }
@@ -45,24 +49,22 @@ const mapDispatchToProps = {
 };
 
 function ActionOptions({
-  actions,
   dashcard,
   parameters,
   onUpdateButtonActionMapping,
-}: ActionOptionsProps & { actions: WritebackAction[] }) {
-  const connectedActionId = dashcard.action_id;
-
-  const selectedAction = actions.find(
-    action => action.id === connectedActionId,
-  );
+}: ActionOptionsProps) {
+  const selectedAction = dashcard.action;
 
   const handleActionSelected = useCallback(
     (action: WritebackAction) => {
       onUpdateButtonActionMapping(dashcard.id, {
-        action_id: action.id,
+        card_id: action.model_id,
         action,
         visualization_settings: updateSettings(
-          { "button.label": action.name },
+          {
+            "button.label": action.name,
+            action_slug: action.slug, // :-( so hacky
+          },
           dashcard.visualization_settings,
         ),
         // Clean mappings from previous action
@@ -83,27 +85,23 @@ function ActionOptions({
   );
 
   return (
-    <>
-      {actions.map(action => (
-        <ActionOptionItem
-          key={action.id}
-          name={action.name}
-          description={action.description}
-          isSelected={action.id === connectedActionId}
-          onClick={() => handleActionSelected(action)}
-        />
-      ))}
-      {selectedAction && (
+    <ActionPickerWrapper>
+      <ActionPicker value={selectedAction} onChange={handleActionSelected} />
+
+      {!!selectedAction && (
         <ClickMappingsContainer>
           <ActionClickMappings
-            action={selectedAction}
+            action={{
+              ...selectedAction,
+              parameters: ensureParamsHaveNames(selectedAction.parameters),
+            }}
             dashcard={dashcard}
             parameters={parameters}
             onChange={handleParameterMappingChange}
           />
         </ClickMappingsContainer>
       )}
-    </>
+    </ActionPickerWrapper>
   );
 }
 
@@ -111,11 +109,11 @@ function ActionOptionsContainer(props: ActionOptionsProps) {
   return (
     <SidebarContent>
       <Heading className="text-medium">{t`Pick an action`}</Heading>
-      <Actions.ListLoader loadingAndErrorWrapper={false}>
-        {({ actions = [] }: { actions: WritebackAction[] }) => (
-          <ActionOptions {...props} actions={actions} />
-        )}
-      </Actions.ListLoader>
+      <ActionOptions
+        dashcard={props.dashcard}
+        parameters={props.parameters}
+        onUpdateButtonActionMapping={props.onUpdateButtonActionMapping}
+      />
     </SidebarContent>
   );
 }
diff --git a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts
index d37b03b5f9519e6fcd797602e3381d03f6dffae9..1f0660e19bf8f8180966f0759b39382e6f2a592e 100644
--- a/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts
+++ b/frontend/src/metabase/dashboard/components/ClickBehaviorSidebar/ActionOptions/utils.ts
@@ -5,6 +5,7 @@ import type {
   ActionParametersMapping,
   ClickBehaviorParameterMapping,
   WritebackAction,
+  WritebackParameter,
 } from "metabase-types/api";
 import type { UiParameter } from "metabase/parameters/types";
 
@@ -71,3 +72,12 @@ export function turnClickBehaviorParameterMappingsIntoDashCardMappings(
 
   return parameter_mappings;
 }
+
+export function ensureParamsHaveNames(
+  parameters: WritebackParameter[],
+): WritebackParameter[] {
+  return parameters.map(parameter => ({
+    ...parameter,
+    name: parameter.name ?? parameter.id,
+  }));
+}
diff --git a/frontend/src/metabase/entities/actions/actions.ts b/frontend/src/metabase/entities/actions/actions.ts
index 790610456ec99ae31755c50b6e0f8a4bd2952d50..584f31136be987ca5cd411cff59f1abb41a538eb 100644
--- a/frontend/src/metabase/entities/actions/actions.ts
+++ b/frontend/src/metabase/entities/actions/actions.ts
@@ -1,8 +1,13 @@
 import { createEntity } from "metabase/lib/entities";
 
-import type { ActionFormSettings } from "metabase-types/api";
+import type {
+  ActionFormSettings,
+  ModelAction,
+  WritebackAction,
+} from "metabase-types/api";
+import type { Dispatch } from "metabase-types/store";
 
-import { CardApi } from "metabase/services";
+import { ActionsApi, CardApi, ModelActionsApi } from "metabase/services";
 
 import {
   removeOrphanSettings,
@@ -11,11 +16,12 @@ import {
   setTemplateTagTypesFromFieldSettings,
 } from "metabase/entities/actions/utils";
 import type Question from "metabase-lib/lib/Question";
-import { saveForm } from "./forms";
+import { saveForm, updateForm } from "./forms";
 
 type ActionParams = {
   name: string;
   description?: string;
+  model_id?: number;
   collection_id?: number;
   question: Question;
   formSettings: ActionFormSettings;
@@ -56,16 +62,79 @@ const getAPIFn =
 const createAction = getAPIFn(CardApi.create);
 const updateAction = getAPIFn(CardApi.update);
 
+const associateAction = ({
+  model_id,
+  action_id,
+}: {
+  model_id: number;
+  action_id: number;
+}) =>
+  ModelActionsApi.connectActionToModel({
+    card_id: model_id,
+    action_id: action_id,
+    slug: `action_${action_id}`,
+    requires_pk: false,
+  });
+
+const defaultImplicitActionCreateOptions = {
+  insert: true,
+  update: true,
+  delete: true,
+};
+
+const enableImplicitActionsForModel =
+  async (modelId: number, options = defaultImplicitActionCreateOptions) =>
+  async (dispatch: Dispatch) => {
+    const methodsToCreate = Object.entries(options)
+      .filter(([, shouldCreate]) => !!shouldCreate)
+      .map(([method]) => method);
+
+    const apiCalls = methodsToCreate.map(method =>
+      ModelActionsApi.createImplicitAction({
+        card_id: modelId,
+        slug: method,
+        requires_pk: method !== "insert",
+      }),
+    );
+
+    await Promise.all(apiCalls);
+
+    dispatch({ type: Actions.actionTypes.INVALIDATE_LISTS_ACTION });
+  };
+
 const Actions = createEntity({
   name: "actions",
   nameOne: "action",
   path: "/api/action",
   api: {
-    create: createAction,
+    create: async ({ model_id, ...params }: ActionParams) => {
+      const card = await createAction(params);
+      if (card?.action_id && model_id) {
+        const association = await associateAction({
+          model_id,
+          action_id: card.action_id,
+        });
+        return { ...card, association };
+      }
+      return card;
+    },
     update: updateAction,
+    list: async (params: any) => {
+      const actions = await ActionsApi.list(params);
+
+      return actions.map((action: ModelAction | WritebackAction) => ({
+        ...action,
+        id: action.id ?? `implicit-${action.slug}`,
+        name: action.name ?? action.slug,
+      }));
+    },
+  },
+  actions: {
+    enableImplicitActionsForModel,
   },
   forms: {
     saveForm,
+    updateForm,
   },
 });
 
diff --git a/frontend/src/metabase/entities/actions/forms.ts b/frontend/src/metabase/entities/actions/forms.ts
index 358ba9b082c9aae2ba32d897092c843f376ee660..bab7fceb86db9deb7a73544c1695b13f624e6885 100644
--- a/frontend/src/metabase/entities/actions/forms.ts
+++ b/frontend/src/metabase/entities/actions/forms.ts
@@ -1,35 +1,41 @@
 import { t } from "ttag";
 
+const getFormFields = (formAction: "create" | "update") => [
+  {
+    name: "name",
+    title: t`Name`,
+    placeholder: t`My new fantastic action`,
+    autoFocus: true,
+    validate: (name: string) =>
+      (!name && t`Name is required`) ||
+      (name && name.length > 100 && t`Name must be 100 characters or less`),
+  },
+  {
+    name: "description",
+    title: t`Description`,
+    type: "text",
+    placeholder: t`It's optional but oh, so helpful`,
+    normalize: (description: string) => description || null, // expected to be nil or non-empty string
+  },
+  {
+    name: "model_id",
+    title: t`Model it's saved in`,
+    type: formAction === "create" ? "model" : "hidden",
+  },
+  {
+    name: "question",
+    type: "hidden",
+  },
+  {
+    name: "formSettings",
+    type: "hidden",
+  },
+];
+
 export const saveForm = {
-  fields: [
-    {
-      name: "name",
-      title: t`Name`,
-      placeholder: t`My new fantastic action`,
-      autoFocus: true,
-      validate: (name: string) =>
-        (!name && t`Name is required`) ||
-        (name && name.length > 100 && t`Name must be 100 characters or less`),
-    },
-    {
-      name: "description",
-      title: t`Description`,
-      type: "text",
-      placeholder: t`It's optional but oh, so helpful`,
-      normalize: (description: string) => description || null, // expected to be nil or non-empty string
-    },
-    {
-      name: "collection_id",
-      title: t`Collection it's saved in`,
-      type: "collection",
-    },
-    {
-      name: "question",
-      type: "hidden",
-    },
-    {
-      name: "formSettings",
-      type: "hidden",
-    },
-  ],
+  fields: getFormFields("create"),
+};
+
+export const updateForm = {
+  fields: getFormFields("update"),
 };
diff --git a/frontend/src/metabase/entities/actions/utils.ts b/frontend/src/metabase/entities/actions/utils.ts
index 3430744eef35ea630a7be92d5ed59b0277f4f00a..856ef75026647ce8fc8a49b09acb19dff7d23dca 100644
--- a/frontend/src/metabase/entities/actions/utils.ts
+++ b/frontend/src/metabase/entities/actions/utils.ts
@@ -4,6 +4,7 @@ import type {
   FieldType,
   InputType,
   ParameterType,
+  ModelAction,
 } from "metabase-types/api";
 
 import { getDefaultFieldSettings } from "metabase/writeback/components/ActionCreator/FormCreator";
diff --git a/frontend/src/metabase/entities/actions/utils.unit.spec.ts b/frontend/src/metabase/entities/actions/utils.unit.spec.ts
index b1bfef64cac82ee8b5056d11b2fa56175f88e951..d734e60f438ec39b5fc3907fcb93d6a45799e078 100644
--- a/frontend/src/metabase/entities/actions/utils.unit.spec.ts
+++ b/frontend/src/metabase/entities/actions/utils.unit.spec.ts
@@ -6,6 +6,7 @@ import {
 import { metadata } from "__support__/sample_database_fixture";
 import type { Parameter as ParameterObject } from "metabase-types/types/Parameter";
 import { NativeDatasetQuery } from "metabase-types/types/Card";
+import { ModelAction } from "metabase-types/api";
 import Question from "metabase-lib/lib/Question";
 
 import {
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.styled.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.styled.tsx
index 31e408302e31535594150984d43156775df8a424..16b7780700acd752434ffa4ec2e0d7c928bf9656 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.styled.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.styled.tsx
@@ -15,7 +15,7 @@ export const ActionListItem = styled(Link)`
 
   width: 100%;
   border-radius: 8px;
-  padding: 1rem 0.5rem;
+  padding: 1rem;
 
   ${ActionTitle} {
     margin-left: 1rem;
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx
index f07856d488fa72163f8ebde53d5f114f0e60fd91..3a2b6661441074ef37cb22098b75f6aba2bf19bb 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx
@@ -1,32 +1,93 @@
 import React from "react";
+import { t } from "ttag";
+import { connect } from "react-redux";
+import _ from "underscore";
 
+import Button from "metabase/core/components/Button";
+import Link from "metabase/core/components/Link";
 import Icon from "metabase/components/Icon";
 
 import Actions from "metabase/entities/actions";
+import { humanize } from "metabase/lib/formatting";
+import { hasImplicitActions, isImplicitAction } from "metabase/writeback/utils";
 
-import type { WritebackQueryAction } from "metabase-types/api";
-import type Question from "metabase-lib/lib/Question";
+import type { WritebackAction } from "metabase-types/api";
+import type { State } from "metabase-types/store";
 
+import {
+  EmptyStateContainer,
+  EmptyStateTitle,
+} from "../ModelDetailPage.styled";
 import { ActionListItem, ActionTitle } from "./ModelActionDetails.styled";
 
+const mapDispatchToProps = {
+  enableImplicitActionsForModel: Actions.actions.enableImplicitActionsForModel,
+};
+
 interface Props {
-  model: Question;
-  actions: WritebackQueryAction[];
+  modelId: number;
+  actions: WritebackAction[];
+  enableImplicitActionsForModel: (modelId: number) => void;
 }
 
-function ModelActionDetails({ actions }: Props) {
+function ModelActionDetails({
+  actions,
+  modelId,
+  enableImplicitActionsForModel,
+}: Props) {
+  const handleCreateImplicitActions = async () => {
+    await enableImplicitActionsForModel(modelId);
+  };
+
+  if (!actions?.length) {
+    return (
+      <EmptyStateContainer>
+        <EmptyStateTitle>{t`This model does not have any actions yet.`}</EmptyStateTitle>
+        <Button onClick={handleCreateImplicitActions} icon="add">
+          {t`Enable implicit actions`}
+        </Button>
+        <Button
+          as={Link}
+          to="/action/create"
+          icon="add"
+        >{t`Create a new action`}</Button>
+      </EmptyStateContainer>
+    );
+  }
+
+  const modelHasImplicitActions = hasImplicitActions(actions);
+
   return (
-    <ul>
-      {actions.map(action => (
-        <li key={action.id}>
-          <ActionListItem to={`/action/${action.id}`}>
-            <Icon name="insight" />
-            <ActionTitle>{action.name}</ActionTitle>
-          </ActionListItem>
-        </li>
-      ))}
-    </ul>
+    <>
+      {!modelHasImplicitActions && modelId && (
+        <Button onClick={handleCreateImplicitActions} icon="add">
+          {t`Enable implicit actions`}
+        </Button>
+      )}
+      <ul>
+        {actions.map(action => (
+          <li key={action.id}>
+            <ActionListItem
+              to={`/action/${action.id}`}
+              disabled={isImplicitAction(action)}
+            >
+              <Icon name="insight" />
+              <ActionTitle>
+                {action.name ?? humanize(action.slug ?? "")}
+              </ActionTitle>
+            </ActionListItem>
+          </li>
+        ))}
+      </ul>
+    </>
   );
 }
 
-export default Actions.loadList()(ModelActionDetails);
+export default Actions.loadList(
+  {
+    query: (state: State, props: { modelId?: number | null }) => ({
+      "model-id": props?.modelId,
+    }),
+  },
+  connect(null, mapDispatchToProps),
+)(ModelActionDetails);
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx
index 715381973bdda94beb17ff9bced58ea489867d05..6cea9850586be0025f37a5da99703f2bc6159512 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.styled.tsx
@@ -52,3 +52,22 @@ export const TabPanel = styled(BaseTabPanel)`
   display: flex;
   flex-direction: column;
 `;
+
+export const EmptyStateContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  gap: 1rem;
+
+  margin-top: 3rem;
+`;
+
+export const EmptyStateTitle = styled.span`
+  display: block;
+  color: ${color("text-medium")};
+  font-size: 1rem;
+  line-height: 1.5rem;
+  margin-bottom: 1rem;
+  text-align: center;
+`;
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx
index f990cb51dd0f3170778e0b13745e57456df9a170..f8e43de0ef873c791f75b2a3a0aec4fbae4495f7 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelDetailPage.tsx
@@ -78,7 +78,7 @@ function ModelDetailPage({ model, mainTable, onChangeModel }: Props) {
             options={[
               { value: "usage", name: t`Used by` },
               { value: "schema", name: t`Schema` },
-              // { value: "actions", name: t`Actions` },
+              { value: "actions", name: t`Actions` },
             ]}
             onChange={tab => setTab(tab as ModelTab)}
           />
@@ -89,7 +89,7 @@ function ModelDetailPage({ model, mainTable, onChangeModel }: Props) {
             <ModelSchemaDetails model={model} />
           </TabPanel>
           <TabPanel value="actions">
-            <ModelActionDetails model={model} />
+            <ModelActionDetails modelId={model.id()} />
           </TabPanel>
         </TabContent>
       </ModelMain>
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.styled.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.styled.tsx
index 5d42399abe454fe60c4dafc4576aeba8b2606947..9770d1d747714bd511939aa943f81510bb3899fb 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.styled.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.styled.tsx
@@ -25,21 +25,3 @@ export const CardListItem = styled(Link)`
     background-color: ${color("brand-light")};
   }
 `;
-
-export const EmptyStateContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  align-items: center;
-
-  margin-top: 3rem;
-`;
-
-export const EmptyStateTitle = styled.span`
-  display: block;
-  color: ${color("text-medium")};
-  font-size: 1rem;
-  line-height: 1.5rem;
-  margin-bottom: 1rem;
-  text-align: center;
-`;
diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.tsx
index b60d2b13a6b92d339111ee85e83409751cc22b07..ff93ad32afe2ec15e1f76762d20f5305315ab0e0 100644
--- a/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.tsx
+++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelUsageDetails/ModelUsageDetails.tsx
@@ -17,13 +17,13 @@ import type { State } from "metabase-types/store";
 import type { Card as LegacyCardType } from "metabase-types/types/Card";
 import type Question from "metabase-lib/lib/Question";
 
-import { isQuestionUsingModel } from "./utils";
 import {
-  CardListItem,
-  CardTitle,
   EmptyStateContainer,
   EmptyStateTitle,
-} from "./ModelUsageDetails.styled";
+} from "../ModelDetailPage.styled";
+
+import { isQuestionUsingModel } from "./utils";
+import { CardListItem, CardTitle } from "./ModelUsageDetails.styled";
 
 interface OwnProps {
   model: Question;
diff --git a/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts b/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts
index d6c5fb486d5e7e2d5b9d4850d5d67650dbabebf9..ff3d5d346d4334e449b153914e801eea2435fb14 100644
--- a/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts
+++ b/frontend/src/metabase/modes/components/drill/ActionClickDrill/ActionClickDrill.unit.spec.ts
@@ -3,7 +3,7 @@ import * as dashboardActions from "metabase/dashboard/actions/writeback";
 import type {
   ActionParametersMapping,
   DashboardOrderedCard,
-  ParameterMappedForActionExecution,
+  ParametersForActionExecution,
   WritebackParameter,
 } from "metabase-types/api";
 import type { ParameterValueOrArray } from "metabase-types/types/Parameter";
@@ -104,12 +104,10 @@ describe("prepareParameter", () => {
       dashboardParamterValues,
     );
 
-    expect(parameter).toEqual({
-      id: DASHBOARD_FILTER_PARAMETER.id,
-      type: WRITEBACK_PARAMETER.type,
-      value: DASHBOARD_FILTER_PARAMETER.value,
-      target: WRITEBACK_PARAMETER.target,
-    });
+    expect(parameter).toEqual([
+      WRITEBACK_PARAMETER.id,
+      DASHBOARD_FILTER_PARAMETER.value,
+    ]);
   });
 
   it("handles array-like parameter value", () => {
@@ -123,12 +121,10 @@ describe("prepareParameter", () => {
       dashboardParamterValues,
     );
 
-    expect(parameter).toEqual({
-      id: DASHBOARD_FILTER_PARAMETER.id,
-      type: WRITEBACK_PARAMETER.type,
-      value: DASHBOARD_FILTER_PARAMETER.value,
-      target: WRITEBACK_PARAMETER.target,
-    });
+    expect(parameter).toEqual([
+      WRITEBACK_PARAMETER.id,
+      DASHBOARD_FILTER_PARAMETER.value,
+    ]);
   });
 });
 
@@ -136,21 +132,16 @@ describe("getNotProvidedActionParameters", () => {
   it("returns empty list if no parameters passed", () => {
     const action = createMockQueryAction();
 
-    const result = getNotProvidedActionParameters(action, []);
+    const result = getNotProvidedActionParameters(action, {});
 
     expect(result).toHaveLength(0);
   });
 
   it("returns empty list if all parameters have values", () => {
     const action = createMockQueryAction({ parameters: [WRITEBACK_PARAMETER] });
-    const result = getNotProvidedActionParameters(action, [
-      {
-        id: DASHBOARD_FILTER_PARAMETER.id,
-        value: 5,
-        type: "number",
-        target: WRITEBACK_PARAMETER.target,
-      },
-    ]);
+    const result = getNotProvidedActionParameters(action, {
+      [WRITEBACK_PARAMETER.id]: 5,
+    });
 
     expect(result).toHaveLength(0);
   });
@@ -160,14 +151,9 @@ describe("getNotProvidedActionParameters", () => {
       parameters: [WRITEBACK_PARAMETER, WRITEBACK_ARBITRARY_PARAMETER],
     });
 
-    const result = getNotProvidedActionParameters(action, [
-      {
-        id: DASHBOARD_FILTER_PARAMETER.id,
-        value: 5,
-        type: "number",
-        target: WRITEBACK_ARBITRARY_PARAMETER.target,
-      },
-    ]);
+    const result = getNotProvidedActionParameters(action, {
+      [WRITEBACK_ARBITRARY_PARAMETER.id]: 5,
+    });
 
     expect(result).toEqual([WRITEBACK_PARAMETER]);
   });
@@ -177,7 +163,7 @@ describe("getNotProvidedActionParameters", () => {
       parameters: [{ ...WRITEBACK_PARAMETER, default: 10 }],
     });
 
-    const result = getNotProvidedActionParameters(action, []);
+    const result = getNotProvidedActionParameters(action, {});
 
     expect(result).toHaveLength(0);
   });
diff --git a/frontend/src/metabase/modes/components/drill/ActionClickDrill/types.ts b/frontend/src/metabase/modes/components/drill/ActionClickDrill/types.ts
index 6be6b8a91a022c3a45393cd312a490e0bf393e01..0c78b38aadf166ebf5446fb8e2cd47417cb998e3 100644
--- a/frontend/src/metabase/modes/components/drill/ActionClickDrill/types.ts
+++ b/frontend/src/metabase/modes/components/drill/ActionClickDrill/types.ts
@@ -1,7 +1,7 @@
 import type {
   ActionDashboardCard,
   Dashboard,
-  ParameterMappedForActionExecution,
+  ParametersForActionExecution,
   WritebackParameter,
 } from "metabase-types/api";
 import type { Column } from "metabase-types/types/Dataset";
@@ -22,7 +22,7 @@ export type ActionClickObject = Omit<ClickObject, "extraData"> & {
   data: any;
   extraData: ActionClickExtraData;
   onSubmit: () => (
-    parameters: ParameterMappedForActionExecution[],
+    parameters: ParametersForActionExecution,
   ) => Promise<boolean>;
   missingParameters: WritebackParameter[];
 };
diff --git a/frontend/src/metabase/modes/components/drill/ActionClickDrill/utils.ts b/frontend/src/metabase/modes/components/drill/ActionClickDrill/utils.ts
index d8db1047c1ad9e2ea3d430d23427ec9e16c73282..545dc91d373a477a909f0e7e02973c19a51ee6a4 100644
--- a/frontend/src/metabase/modes/components/drill/ActionClickDrill/utils.ts
+++ b/frontend/src/metabase/modes/components/drill/ActionClickDrill/utils.ts
@@ -4,9 +4,11 @@ import { isEmpty } from "metabase/lib/validate";
 import type {
   ActionDashboardCard,
   ActionParametersMapping,
-  ParameterMappedForActionExecution,
+  ParametersForActionExecution,
   WritebackAction,
   WritebackParameter,
+  ParameterId,
+  ActionParameterValue,
 } from "metabase-types/api";
 import type { ParameterValueOrArray } from "metabase-types/types/Parameter";
 
@@ -14,25 +16,29 @@ function formatParameterValue(value: ParameterValueOrArray) {
   return Array.isArray(value) ? value[0] : value;
 }
 
+type ActionParameterTuple = [ParameterId, ActionParameterValue];
+
 export function getDashcardParamValues(
   dashcard: ActionDashboardCard,
   parameterValues: { [id: string]: ParameterValueOrArray },
-) {
+): ParametersForActionExecution {
   if (!dashcard.action || !dashcard?.parameter_mappings?.length) {
-    return [];
+    return {};
   }
   const { action, parameter_mappings } = dashcard;
 
-  return parameter_mappings
-    .map(mapping => prepareParameter(mapping, action, parameterValues))
-    .filter(Boolean) as ParameterMappedForActionExecution[];
+  return Object.fromEntries(
+    parameter_mappings
+      ?.map(mapping => prepareParameter(mapping, action, parameterValues))
+      ?.filter(Boolean) as ActionParameterTuple[],
+  );
 }
 
 export function prepareParameter(
   mapping: ActionParametersMapping,
   action: WritebackAction,
   parameterValues: { [id: string]: ParameterValueOrArray },
-) {
+): ActionParameterTuple | undefined {
   const { parameter_id: sourceParameterId, target: actionParameterTarget } =
     mapping;
 
@@ -46,29 +52,22 @@ export function prepareParameter(
     return;
   }
 
-  return {
-    id: sourceParameterId,
-    type: actionParameter.type,
-    value: formatParameterValue(parameterValue),
-    target: actionParameterTarget,
-  };
+  return [actionParameter.id, formatParameterValue(parameterValue)];
 }
 
 function isMappedParameter(
   parameter: WritebackParameter,
-  parameterMappings: ParameterMappedForActionExecution[],
+  dashboardParamValues: ParametersForActionExecution,
 ) {
-  return parameterMappings.some(mapping =>
-    _.isEqual(mapping.target, parameter.target),
-  );
+  return parameter.id in dashboardParamValues;
 }
 
 export function getNotProvidedActionParameters(
   action: WritebackAction,
-  dashboardParamValues: ParameterMappedForActionExecution[],
+  dashboardParamValues: ParametersForActionExecution,
 ) {
   // return any action parameters that don't have mapped values
-  return action.parameters.filter(parameter => {
+  return (action.parameters ?? []).filter(parameter => {
     if ("default" in parameter) {
       return false;
     }
diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx
index 85aaaf7011dde080bc15b7ff484d74c1b7459863..348a0e64ff246582be349be912d94728592bce74 100644
--- a/frontend/src/metabase/routes.jsx
+++ b/frontend/src/metabase/routes.jsx
@@ -92,7 +92,6 @@ import { trackPageView } from "metabase/lib/analytics";
 import { getAdminPaths } from "metabase/admin/app/selectors";
 
 import ActionPage from "metabase/writeback/containers/ActionCreatorPage";
-import ActionsListPage from "metabase/writeback/containers/ActionsListPage";
 
 import ModelDetailPage from "metabase/models/containers/ModelDetailPage";
 
@@ -401,8 +400,6 @@ export const getRoutes = store => (
           <Route path="create" component={ActionPage} />
           <Route path=":actionId" component={ActionPage} />
         </Route>
-        {/* DEV PAGE: REMOVE BEFORE SHIPPING */}
-        <Route path="/actions" component={ActionsListPage} />
       </Route>
     </Route>
 
diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js
index 3a2890f3d6f4aa53eb3bd71fc4b3775048c4e68c..294a911256622a404dd56e333044f812880977fc 100644
--- a/frontend/src/metabase/services.js
+++ b/frontend/src/metabase/services.js
@@ -476,12 +476,20 @@ function setParamsEndpoints(prefix) {
 }
 
 export const ActionsApi = {
+  list: GET("/api/action"),
   create: POST("/api/action/row/create"),
   update: POST("/api/action/row/update"),
   delete: POST("/api/action/row/delete"),
   bulkUpdate: POST("/api/action/bulk/update/:tableId"),
   bulkDelete: POST("/api/action/bulk/delete/:tableId"),
   execute: POST(
-    "/api/dashboard/:dashboardId/dashcard/:dashcardId/action/execute",
+    "/api/dashboard/:dashboardId/dashcard/:dashcardId/execute/:slug",
   ),
 };
+
+export const ModelActionsApi = {
+  connectActionToModel: POST("/api/model-action"),
+  createImplicitAction: POST("/api/model-action"),
+  updateConnection: PUT("/api/model-action/:id"),
+  disconnectActionFromModel: POST("/api/model-action"),
+};
diff --git a/frontend/src/metabase/visualizations/components/List/List.tsx b/frontend/src/metabase/visualizations/components/List/List.tsx
index cba58861b4131a025bd08a5a6c6883e040021a71..fb567481163b91c5407f68d7ba01319e4bbe515f 100644
--- a/frontend/src/metabase/visualizations/components/List/List.tsx
+++ b/frontend/src/metabase/visualizations/components/List/List.tsx
@@ -253,8 +253,7 @@ function List({
     );
   }, [connectedDashCard, settings, bulkActions]);
 
-  const hasInlineActions =
-    !isSelectingItems && (hasEditButton || hasDeleteButton);
+  const hasInlineActions = false; // TODO remove completely
 
   const renderBulkSelectionControl = useCallback(
     (rowIndex: number) => {
diff --git a/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx b/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx
index 5e63132a1be90ec8ed07919edbf547eba491e2b6..b2763d9aca8aa0c1560427d1cafe7485ee9432a5 100644
--- a/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx
+++ b/frontend/src/metabase/writeback/components/ActionCreator/ActionCreator.tsx
@@ -110,7 +110,7 @@ function ActionCreatorComponent({
         <Modal onClose={handleClose}>
           <Actions.ModalForm
             title={isNew ? t`New action` : t`Save action`}
-            form={Actions.forms.saveForm}
+            form={isNew ? Actions.forms.saveForm : Actions.forms.updateForm}
             action={{
               id: (question.card() as SavedCard).id,
               name: question.displayName(),
diff --git a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts
index af5f16f0c991e10420d7fe3540d3a877ceae9b14..4a7b5ef31ed38c4ff67da170368c297c8928cbb8 100644
--- a/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts
+++ b/frontend/src/metabase/writeback/components/ActionCreator/FormCreator/utils.ts
@@ -4,6 +4,7 @@ import type {
   ActionFormSettings,
   WritebackAction,
   FieldSettings,
+  ParameterId,
 } from "metabase-types/api";
 
 import validate from "metabase/lib/validate";
@@ -102,7 +103,7 @@ export const getFormFieldForParameter = (
   fieldSettings: FieldSettings,
 ) => ({
   name: parameter.id,
-  title: parameter.name,
+  title: parameter.name ?? parameter.id,
   ...getParameterFieldProps(fieldSettings),
 });
 
@@ -111,3 +112,16 @@ export const getFormTitle = (action: WritebackAction): string =>
 
 export const getSubmitButtonLabel = (action: WritebackAction): string =>
   action.visualization_settings?.submitButtonLabel || t`Save`;
+
+export const generateFieldSettingsFromParameters = (params: Parameter[]) => {
+  const fieldSettings: Record<ParameterId, FieldSettings> = {};
+
+  params.forEach(param => {
+    fieldSettings[param.id] = getDefaultFieldSettings({
+      name: param.name ?? param.id,
+      fieldType: param.type.includes("Integer") ? "number" : "string",
+      inputType: param.type.includes("Integer") ? "number" : "string",
+    });
+  });
+  return fieldSettings;
+};
diff --git a/frontend/src/metabase/writeback/components/ActionViz/Action.tsx b/frontend/src/metabase/writeback/components/ActionViz/Action.tsx
index 4e55b8f09ed0bb2d922f3f71d65458f1f63b62f5..fd6c39c6f28e4d54f8b297915443237d71782eb9 100644
--- a/frontend/src/metabase/writeback/components/ActionViz/Action.tsx
+++ b/frontend/src/metabase/writeback/components/ActionViz/Action.tsx
@@ -6,10 +6,8 @@ import { isImplicitActionButton } from "metabase/writeback/utils";
 
 import type {
   ActionDashboardCard,
-  ArbitraryParameterForActionExecution,
-  ActionParametersMapping,
   Dashboard,
-  ParameterMappedForActionExecution,
+  ParametersForActionExecution,
   WritebackQueryAction,
 } from "metabase-types/api";
 import type { VisualizationProps } from "metabase-types/types/Visualization";
@@ -19,7 +17,6 @@ import type { ParameterValueOrArray } from "metabase-types/types/Parameter";
 import {
   getDashcardParamValues,
   getNotProvidedActionParameters,
-  prepareParameter,
 } from "metabase/modes/components/drill/ActionClickDrill/utils";
 import { executeRowAction } from "metabase/dashboard/actions";
 import { StyledButton } from "./ActionButton.styled";
@@ -69,12 +66,14 @@ function ActionComponent({
     actionDisplayType !== "inline" || !missingParameters.length;
 
   const onSubmit = useCallback(
-    (extra_parameters: ArbitraryParameterForActionExecution[]) =>
+    (parameterMap: ParametersForActionExecution) =>
       executeRowAction({
         dashboard,
         dashcard,
-        parameters: dashcardParamValues,
-        extra_parameters,
+        parameters: {
+          ...dashcardParamValues,
+          ...parameterMap,
+        },
         dispatch,
         shouldToast: shouldDisplayButton,
       }),
diff --git a/frontend/src/metabase/writeback/components/ActionViz/ActionForm.tsx b/frontend/src/metabase/writeback/components/ActionViz/ActionForm.tsx
index 7fa68765389cede68ad7b74ffbf1235cfa6e0199..ecd3901e2817e073d034bb37356c6fc39c055b82 100644
--- a/frontend/src/metabase/writeback/components/ActionViz/ActionForm.tsx
+++ b/frontend/src/metabase/writeback/components/ActionViz/ActionForm.tsx
@@ -1,7 +1,6 @@
 import React from "react";
 
 import type {
-  ArbitraryParameterForActionExecution,
   WritebackQueryAction,
   WritebackParameter,
   OnSubmitActionForm,
diff --git a/frontend/src/metabase/writeback/containers/ActionParametersInputForm/ActionParametersInputForm.tsx b/frontend/src/metabase/writeback/containers/ActionParametersInputForm/ActionParametersInputForm.tsx
index 1b90a2bd4dc91ecc6456f695bb189aeb667d199b..3bc2786440852dc0bb595f4e153c61f410e67969 100644
--- a/frontend/src/metabase/writeback/containers/ActionParametersInputForm/ActionParametersInputForm.tsx
+++ b/frontend/src/metabase/writeback/containers/ActionParametersInputForm/ActionParametersInputForm.tsx
@@ -4,6 +4,7 @@ import Form from "metabase/containers/FormikForm";
 import {
   getFormFieldForParameter,
   getSubmitButtonLabel,
+  generateFieldSettingsFromParameters,
 } from "metabase/writeback/components/ActionCreator/FormCreator";
 
 import type {
@@ -11,9 +12,8 @@ import type {
   WritebackQueryAction,
   OnSubmitActionForm,
 } from "metabase-types/api";
-import type { FormFieldDefinition } from "metabase-types/forms";
 
-import { formatParametersBeforeSubmit, setDefaultValues } from "./utils";
+import { setDefaultValues, setNumericValues } from "./utils";
 
 interface Props {
   missingParameters: WritebackParameter[];
@@ -29,10 +29,11 @@ function ActionParametersInputForm({
   onSubmitSuccess,
 }: Props) {
   const fieldSettings = useMemo(
-    () => action.visualization_settings?.fields ?? {},
-    [action],
+    () =>
+      action.visualization_settings?.fields ??
+      generateFieldSettingsFromParameters(missingParameters),
+    [action, missingParameters],
   );
-
   const formParams = useMemo(
     () => missingParameters ?? Object.values(action.parameters) ?? [],
     [missingParameters, action],
@@ -41,7 +42,7 @@ function ActionParametersInputForm({
   const form = useMemo(() => {
     return {
       fields: formParams?.map(param =>
-        getFormFieldForParameter(param, fieldSettings[param.id]),
+        getFormFieldForParameter(param, fieldSettings[param.id] ?? {}),
       ),
     };
   }, [formParams, fieldSettings]);
@@ -50,12 +51,13 @@ function ActionParametersInputForm({
     async (params, actions) => {
       actions.setSubmitting(true);
       const paramsWithDefaultValues = setDefaultValues(params, fieldSettings);
-
-      const formattedParams = formatParametersBeforeSubmit(
+      const paramsWithNumericValues = setNumericValues(
         paramsWithDefaultValues,
-        formParams,
+        fieldSettings,
       );
-      const { success, error } = await onSubmit(formattedParams);
+
+      const { success, error } = await onSubmit(paramsWithNumericValues);
+
       if (success) {
         actions.setErrors({});
         onSubmitSuccess?.();
@@ -64,7 +66,7 @@ function ActionParametersInputForm({
         throw new Error(error);
       }
     },
-    [onSubmit, onSubmitSuccess, fieldSettings, formParams],
+    [onSubmit, onSubmitSuccess, fieldSettings],
   );
 
   const initialValues = useMemo(
diff --git a/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.ts b/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.ts
index d9669959f8ae495177b43ee9dcb1c51ea6283dd5..64262d9e51d53ca2204d72921c6b2af4dc6777fb 100644
--- a/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.ts
+++ b/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.ts
@@ -1,53 +1,31 @@
 import { isEmpty } from "metabase/lib/validate";
 
 import type {
-  ArbitraryParameterForActionExecution,
-  WritebackParameter,
   FieldSettings,
+  ParametersForActionExecution,
 } from "metabase-types/api";
 
-import type { Parameter, ParameterId } from "metabase-types/types/Parameter";
-
-type ParameterMap = Record<ParameterId, string | number>;
-
-function getActionParameterType(parameter: Parameter) {
-  const { type } = parameter;
-  if (type === "category") {
-    return "string/=";
-  }
-  return type;
-}
-
-export function formatParametersBeforeSubmit(
-  values: ParameterMap,
-  missingParameters: WritebackParameter[],
+// set user-defined default values for any non-required empty parameters
+export function setDefaultValues(
+  params: ParametersForActionExecution,
+  fieldSettings: { [tagId: string]: FieldSettings },
 ) {
-  const formattedParams: ArbitraryParameterForActionExecution[] = [];
-
-  Object.keys(values).forEach(parameterId => {
-    const parameter = missingParameters.find(
-      parameter => parameter.id === parameterId,
-    );
-    if (parameter) {
-      formattedParams.push({
-        value: values[parameterId],
-        type: getActionParameterType(parameter),
-        target: parameter.target,
-      });
+  Object.entries(params).forEach(([key, value]) => {
+    if (isEmpty(value) && fieldSettings[key] && !fieldSettings[key].required) {
+      params[key] = fieldSettings[key].defaultValue ?? "";
     }
   });
 
-  return formattedParams;
+  return params;
 }
 
-// set user-defined default values for any non-required empty parameters
-export function setDefaultValues(
-  params: ParameterMap,
+export function setNumericValues(
+  params: ParametersForActionExecution,
   fieldSettings: { [tagId: string]: FieldSettings },
 ) {
   Object.entries(params).forEach(([key, value]) => {
-    if (isEmpty(value) && fieldSettings[key] && !fieldSettings[key].required) {
-      params[key] = fieldSettings[key].defaultValue ?? "";
+    if (fieldSettings[key]?.fieldType === "number") {
+      params[key] = Number(value) ?? 0;
     }
   });
 
diff --git a/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.unit.spec.ts b/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.unit.spec.ts
index c70f251c0ffcb6e1edf63130c386535f9e6b7612..5b9a7eebe791f12238923a214cbd857e7d02171b 100644
--- a/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.unit.spec.ts
+++ b/frontend/src/metabase/writeback/containers/ActionParametersInputForm/utils.unit.spec.ts
@@ -1,41 +1,7 @@
 import type { FieldSettingsMap } from "metabase-types/api";
-import { formatParametersBeforeSubmit, setDefaultValues } from "./utils";
+import { setDefaultValues } from "./utils";
 
 describe("writeback > containers > ActionParametersInputForm > utils", () => {
-  describe("formatParametersBeforeSubmit", () => {
-    it("should format with their ids for the API", () => {
-      const values = {
-        "abc-def": "1",
-        "ghi-jkl": 2,
-      };
-      const missingParameters: any = [
-        {
-          id: "abc-def",
-          type: "string/=",
-          target: "",
-        },
-        {
-          id: "ghi-jkl",
-          type: "number/=",
-          target: "",
-        },
-      ];
-      const result = formatParametersBeforeSubmit(values, missingParameters);
-      expect(result).toEqual([
-        {
-          value: "1",
-          type: "string/=",
-          target: "",
-        },
-        {
-          value: 2,
-          type: "number/=",
-          target: "",
-        },
-      ]);
-    });
-  });
-
   describe("setDefaultValues", () => {
     it("should set a default value for a non-required empty form field", () => {
       const params = {
diff --git a/frontend/src/metabase/writeback/containers/ActionsListPage.tsx b/frontend/src/metabase/writeback/containers/ActionsListPage.tsx
deleted file mode 100644
index 03832cb621012d683de52723bb9b8a0a017d36dc..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/writeback/containers/ActionsListPage.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-// dev page, should NOT be shipped
-
-import React from "react";
-import { Link } from "react-router";
-import _ from "underscore";
-import { connect } from "react-redux";
-
-import Actions from "metabase/entities/actions";
-import type { WritebackQueryAction } from "metabase-types/api";
-
-function ActionsListPage({ actions }: { actions: WritebackQueryAction[] }) {
-  return (
-    <div className="p4">
-      <div className="flex justify-between">
-        <h1>Actions</h1>
-        <Link to="/action/create" className="Button Button--primary">
-          Create Action
-        </Link>
-      </div>
-      <div className="bordered rounded mt3">
-        {actions
-          ?.sort((a, b) => b.id - a.id)
-          .map(action => (
-            <ActionListItem action={action} key={action.id} />
-          ))}
-      </div>
-    </div>
-  );
-}
-
-const ActionListItem = ({ action }: { action: WritebackQueryAction }) => {
-  return (
-    <Link to={`/action/${action.id}`}>
-      <div className="border-bottom p1" style={{}}>
-        <strong>
-          <span className="text-primary">{action.name}</span>
-          <span className="text-light ml1">{action.id}</span>
-        </strong>
-        {!!action.description && (
-          <div className="mt1">{action.description}</div>
-        )}
-      </div>
-    </Link>
-  );
-};
-
-export default _.compose(
-  Actions.loadList(),
-  connect(ActionsListPage),
-)(ActionsListPage);
diff --git a/frontend/src/metabase/writeback/utils.ts b/frontend/src/metabase/writeback/utils.ts
index 063fb2a669b9e6c7badf0ba9d78c142fd840e4e2..41acafd2886c7094c517443a57c9d93348fd434c 100644
--- a/frontend/src/metabase/writeback/utils.ts
+++ b/frontend/src/metabase/writeback/utils.ts
@@ -3,6 +3,7 @@ import type {
   BaseDashboardOrderedCard,
   ClickBehavior,
   Database as IDatabase,
+  WritebackAction,
 } from "metabase-types/api";
 import type { SavedCard } from "metabase-types/types/Card";
 import { TYPE } from "metabase-lib/lib/types/constants";
@@ -81,7 +82,9 @@ export function isMappedExplicitActionButton(
   dashCard: BaseDashboardOrderedCard,
 ): dashCard is ActionDashboardCard {
   const isAction = isActionDashCard(dashCard);
-  return isAction && typeof dashCard.action_id === "number";
+  return (
+    isAction && typeof dashCard.visualization_settings.action_slug === "string"
+  );
 }
 
 export function isValidImplicitActionClickBehavior(
@@ -123,3 +126,9 @@ export function getActionButtonLabel(dashCard: ActionDashboardCard) {
   const label = dashCard.visualization_settings?.["button.label"];
   return label || "";
 }
+
+export const hasImplicitActions = (actions: WritebackAction[]): boolean =>
+  actions.some(isImplicitAction);
+
+export const isImplicitAction = (action: WritebackAction): boolean =>
+  action.type === "implicit";
diff --git a/frontend/src/metabase/writeback/utils.unit.spec.ts b/frontend/src/metabase/writeback/utils.unit.spec.ts
index 3c46d3d2b51d585436fd9d5ff2198b0b65e20b40..a839db3031f829fa32c9d94b9268411c71fe228d 100644
--- a/frontend/src/metabase/writeback/utils.unit.spec.ts
+++ b/frontend/src/metabase/writeback/utils.unit.spec.ts
@@ -19,7 +19,10 @@ const QUERY_ACTION = createMockQueryAction();
 const EXPLICIT_ACTION = createMockDashboardActionButton({
   action_id: QUERY_ACTION.id,
   action: QUERY_ACTION,
-  visualization_settings: { click_behavior: undefined },
+  visualization_settings: {
+    click_behavior: undefined,
+    action_slug: "action_1337",
+  },
 });
 
 const PARAMETER_MAPPINGS: ActionParametersMapping[] = [
diff --git a/resources/migrations/000_migrations.yaml b/resources/migrations/000_migrations.yaml
index 6dbab243a1dabf875e463c89617d1be287e4b262..d230622e796f932c740762188373679314b4ec12 100644
--- a/resources/migrations/000_migrations.yaml
+++ b/resources/migrations/000_migrations.yaml
@@ -12825,6 +12825,80 @@ databaseChangeLog:
               CHANGE updated_at
               updated_at timestamp(6) NOT NULL DEFAULT current_timestamp(6);
 
+  - changeSet:
+      id: v45.00-036
+      author: snoe
+      comment: Added 0.45.0 - add model action table
+      changes:
+        - createTable:
+            tableName: model_action
+            remarks: Ties actions to models
+            columns:
+              - column:
+                  name: id
+                  type: int
+                  autoIncrement: true
+                  constraints:
+                    primaryKey: true
+                    nullable: false
+              - column:
+                  name: entity_id
+                  type: char(21)
+                  remarks: Random NanoID tag for unique identity.
+                  constraints:
+                    nullable: false
+                    unique: true
+              - column:
+                  name: slug
+                  type: varchar(254)
+                  remarks: The referenceable name for this action
+                  constraints:
+                    nullable: false
+              - column:
+                  name: card_id
+                  type: int
+                  remarks: The associated model
+                  constraints:
+                    nullable: false
+                    referencedTableName: report_card
+                    referencedColumnNames: id
+                    foreignKeyName: fk_model_action_ref_card_id
+                    deleteCascade: true
+              - column:
+                  name: action_id
+                  type: int
+                  remarks: The associated action
+                  constraints:
+                    nullable: true
+                    referencedTableName: action
+                    referencedColumnNames: id
+                    foreignKeyName: fk_model_action_ref_action_id
+                    deleteCascade: true
+              - column:
+                  name: requires_pk
+                  remarks: Indicates that the primary key(s) need to be passed in as parameters
+                  type: boolean
+                  defaultValueBoolean: false
+                  constraints:
+                    nullable: false
+              - column:
+                  name: parameter_mappings
+                  type: ${text.type}
+                  remarks: Mappings for primary keys to action parameters
+              - column:
+                  name: visualization_settings
+                  type: ${text.type}
+                  remarks: Settings for rendering the action
+  - changeSet:
+      id: v45.00-037
+      author: snoe
+      comment: Added 0.45.0 - model action
+      changes:
+        - addUniqueConstraint:
+            tableName: model_action
+            columnNames: card_id, slug
+            constraintName: unique_model_action_card_id_slug
+
   # The next 4 values add default values for Database `created_at` and `updated_at`; previously this was handled by
   # Toucan but it's more convenient to do this at the application database level instead -- it facilitates stuff like
   # schema migration tests that don't use Toucan, or other manual scripting
@@ -12919,7 +12993,6 @@ databaseChangeLog:
             tableName: metabase_database
             columnName: details
 
-
 # >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
 
 ########################################################################################################################
diff --git a/src/metabase/actions/execution.clj b/src/metabase/actions/execution.clj
index 58da59969822a6d66a333119f309841fb48f6c2f..12e147a6ba945ef6b0d1dc788bf9c26281c7b62b 100644
--- a/src/metabase/actions/execution.clj
+++ b/src/metabase/actions/execution.clj
@@ -1,63 +1,28 @@
 (ns metabase.actions.execution
   (:require
+    [clojure.set :as set]
     [clojure.tools.logging :as log]
     [medley.core :as m]
     [metabase.actions :as actions]
     [metabase.actions.http-action :as http-action]
     [metabase.api.common :as api]
-    [metabase.mbql.normalize :as mbql.normalize]
-    [metabase.models :refer [Dashboard DashboardCard]]
+    [metabase.models :refer [Card Dashboard DashboardCard ModelAction Table]]
     [metabase.models.action :as action]
+    [metabase.models.query :as query]
     [metabase.query-processor.error-type :as qp.error-type]
     [metabase.query-processor.middleware.permissions :as qp.perms]
     [metabase.query-processor.writeback :as qp.writeback]
     [metabase.util :as u]
     [metabase.util.i18n :refer [tru]]
-    [toucan.db :as db]))
-
-(defn- map-parameters
-  "Take the `parameters` map passed in to an endpoint and map it to the parameters
-  in the underlying `Action` so they can be attached to the query that gets passed to
-   [[qp/execute-write-query!]] or [[execute-http-action!]].
-
-  Incoming API request `:parameters` should look like
-
-    [{:id \"my_id\",
-      :type \"id\",
-      :value \"12\"}]
-
-  And `parameter_mappings` should look like
-
-     [{:parameter_id \"my_id\", :target [:variable [:template-tag \"id\"]]}]
-
-  We need to convert these to a list like
-
-    [{:id \"my_id\"
-      :type \"id\"
-      :target [:variable [:template-tag \"id\"]]
-      :value  \"12\"}]
-
-  before passing to the QP code."
-  [parameters parameter-mappings]
-  (let [mappings-by-id (m/index-by :parameter_id parameter-mappings)]
-    (mapv (fn [{param-id :id :as parameter}]
-            (let [target (or (get-in mappings-by-id [param-id :target])
-                             (throw (ex-info (tru "No parameter mapping found for parameter {0}. Found: {1}"
-                                                  (pr-str param-id)
-                                                  (pr-str (set (map :parameter_id parameter-mappings))))
-                                             {:status-code 400
-                                              :type        qp.error-type/invalid-parameter
-                                              :parameters  parameters
-                                              :mappings    parameter-mappings})))]
-              (assoc parameter :target target)))
-          parameters)))
+    [toucan.db :as db]
+    [toucan.hydrate :refer [hydrate]]))
 
 (defn- execute-query-action!
   "Execute a `QueryAction` with parameters as passed in from an
-  endpoint (see [[map-parameters]] for a description of their shape).
+  endpoint of shape `{<parameter-id> <value>}`.
 
   `action` should already be hydrated with its `:card`."
-  [{:keys [card] action-id :id :as action} parameters]
+  [{:keys [card] action-id :id :as action} request-parameters]
   (when-not card
     (throw (ex-info (tru "No Card found for Action {0}." action-id)
                     {:status-code 400, :action action})))
@@ -68,7 +33,9 @@
                     {:status-code 400, :action action})))
   (log/tracef "Executing action\n\n%s" (u/pprint-to-str action))
   (try
-    (let [query (assoc (:dataset_query card) :parameters parameters)]
+    (let [parameters (for [parameter (:parameters action)]
+                       (assoc parameter :value (get request-parameters (:id parameter))))
+          query (assoc (:dataset_query card) :parameters parameters)]
       (log/debugf "Query (before preprocessing):\n\n%s" (u/pprint-to-str query))
       (binding [qp.perms/*card-id* (:id card)]
         (qp.writeback/execute-write-query! query)))
@@ -77,50 +44,105 @@
         (api/throw-403 e)
         (throw (ex-info (tru "Error executing Action: {0}" (ex-message e))
            {:action     action
-            :parameters parameters}
+            :parameters request-parameters}
            e))))))
 
-(defn- execute-http-action!
-  [action parameters]
-  (let [params->value (->> parameters
-                           (map (juxt (comp second :target) :value))
-                           (into {}))]
-    (http-action/execute-http-action! action params->value)))
-
-(defn execute-dashcard!
-  "Execute the given action in the dashboard/dashcard context with the given unmapped-parameters and extra-parameters.
-   See [[map-parameters]] for a description of their expected shapes."
-  [dashboard-id dashcard-id unmapped-parameters extra-parameters]
-  (actions/check-actions-enabled)
-  (api/read-check Dashboard dashboard-id)
-  (let [dashcard (api/check-404 (db/select-one DashboardCard
-                                               :id dashcard-id
-                                               :dashboard_id dashboard-id))
-        action (api/check-404 (first (action/select-actions :id (:action_id dashcard))))
+(defn- execute-custom-action [action-id request-parameters]
+  (let [action (api/check-404 (first (action/select-actions :id action-id)))
         action-type (:type action)
-        _ (log/tracef "Mapping parameters\n\n%s\nwith mappings\n\n%s"
-                      (u/pprint-to-str unmapped-parameters)
-                      (u/pprint-to-str (:parameter_mappings dashcard)))
-        mapped-parameters (map-parameters
-                            (mbql.normalize/normalize-fragment [:parameters] unmapped-parameters)
-                            (:parameter_mappings dashcard))
-        parameters (into (mbql.normalize/normalize-fragment [:parameters] extra-parameters)
-                         mapped-parameters)
-        destination-parameters-by-target (m/index-by :target (:parameters action))]
-    (doseq [{:keys [target]} parameters]
-      (when-not (contains? destination-parameters-by-target target)
-        (throw (ex-info (tru "No destination parameter found for target {0}. Found: {1}"
-                             (pr-str target)
-                             (pr-str (set (keys destination-parameters-by-target))))
+        destination-parameters-by-id (m/index-by :id (:parameters action))]
+    (doseq [[parameter-id _value] request-parameters]
+      (when-not (contains? destination-parameters-by-id parameter-id)
+        (throw (ex-info (tru "No destination parameter found for id {0}. Found: {1}"
+                             (pr-str parameter-id)
+                             (pr-str (set (keys destination-parameters-by-id))))
                         {:status-code 400
                          :type qp.error-type/invalid-parameter
-                         :parameters parameters
+                         :parameters request-parameters
                          :destination-parameters (:parameters action)}))))
     (case action-type
       :query
-      (execute-query-action! action parameters)
+      (execute-query-action! action request-parameters)
 
       :http
-      (execute-http-action! action parameters)
+      (http-action/execute-http-action! action request-parameters)
 
       (throw (ex-info (tru "Unknown action type {0}." (name action-type)) action)))))
+
+(defn- implicit-action-table
+  [card_id]
+  (let [card (db/select-one Card :id card_id)
+        {:keys [table-id]} (query/query->database-and-table-ids (:dataset_query card))]
+    (hydrate (db/select-one Table :id table-id) :fields)))
+
+(defn- execute-implicit-action
+  [{:keys [card_id slug requires_pk] :as _model-action} request-parameters]
+  (let [{database-id :db_id table-id :id :as table} (implicit-action-table card_id)
+        pk-fields (filterv #(isa? (:semantic_type %) :type/PK) (:fields table))
+        slug->field-name (into {} (map (juxt (comp u/slugify :name) :name)) (:fields table))
+        _ (api/check (action/unique-field-slugs? (:fields table))
+                     400
+                     (tru "Cannot execute implicit action on a table with ambiguous column names."))
+        _ (api/check (= (count pk-fields) 1)
+                     400
+                     (tru "Must execute implicit action on a table with a single primary key."))
+        extra-parameters (set/difference (set (keys request-parameters))
+                                         (set (keys slug->field-name)))
+
+        pk-field (first pk-fields)
+        simple-parameters (update-keys request-parameters (comp keyword slug->field-name))
+        pk-field-name (keyword (:name pk-field))
+        _ (api/check (empty? extra-parameters)
+                     400
+                     {:message (tru "No destination parameter found for {0}. Found: {1}"
+                                    (pr-str extra-parameters)
+                                    (pr-str (set (keys slug->field-name))))
+                      :parameters request-parameters
+                      :destination-parameters (keys slug->field-name)})
+
+        _ (api/check (or (not requires_pk)
+                         (some? (get simple-parameters pk-field-name)))
+                     400
+                     (tru "Missing primary key parameter: {0}"
+                          (pr-str (u/slugify (:name pk-field)))))
+        query (cond-> {:database database-id,
+                       :type :query,
+                       :query {:source-table table-id}}
+                requires_pk
+                (assoc-in [:query :filter]
+                          [:= [:field (:id pk-field) nil] (get simple-parameters pk-field-name)]))
+        implicit-action (cond
+                          (= slug "delete")
+                          :row/delete
+
+                          requires_pk
+                          :row/update
+
+                          :else
+                          :row/create)
+        row-parameters (cond-> simple-parameters
+                         (not= implicit-action :row/create) (dissoc pk-field-name))
+        _ (api/check (or (= implicit-action :row/delete) (seq row-parameters))
+                     400
+                     (tru "Implicit parameters must be provided."))
+        arg-map (cond-> query
+                  (= implicit-action :row/create)
+                  (assoc :create-row row-parameters)
+
+                  (= implicit-action :row/update)
+                  (assoc :update-row row-parameters))]
+    (actions/perform-action! implicit-action arg-map)))
+
+(defn execute-dashcard!
+  "Execute the given action in the dashboard/dashcard context with the given parameters
+   of shape `{<parameter-id> <value>}."
+  [dashboard-id dashcard-id slug request-parameters]
+  (actions/check-actions-enabled)
+  (api/read-check Dashboard dashboard-id)
+  (let [dashcard (api/check-404 (db/select-one DashboardCard
+                                               :id dashcard-id
+                                               :dashboard_id dashboard-id))
+        model-action (api/check-404 (db/select-one ModelAction :card_id (:card_id dashcard) :slug slug))]
+    (if-let [action-id (:action_id model-action)]
+      (execute-custom-action action-id request-parameters)
+      (execute-implicit-action model-action request-parameters))))
diff --git a/src/metabase/api/action.clj b/src/metabase/api/action.clj
index b25dd82483250a49684eacfcc1bcef77c2f6c372..1125c52e09dcb5e502b11776fe13a1e91d040693 100644
--- a/src/metabase/api/action.clj
+++ b/src/metabase/api/action.clj
@@ -71,8 +71,9 @@
 
 (api/defendpoint GET "/"
   "Returns cards that can be used for QueryActions"
-  []
-  (hydrate (action/select-actions) :action/emitter-usages))
+  [model-id]
+  {model-id su/IntGreaterThanZero}
+  (action/merged-model-action nil :card_id model-id))
 
 (api/defendpoint GET "/:action-id"
   [action-id]
diff --git a/src/metabase/api/app.clj b/src/metabase/api/app.clj
index 2a8557235fe28e483fc5af39570e187c632367b0..2a1c09ca840a1fb44d4aacf46cd6fa2031f3a8da 100644
--- a/src/metabase/api/app.clj
+++ b/src/metabase/api/app.clj
@@ -7,6 +7,7 @@
     [metabase.api.card :as api.card]
     [metabase.api.collection :as api.collection]
     [metabase.api.common :as api]
+    [metabase.mbql.schema :as mbql.s]
     [metabase.models :refer [App Collection Dashboard Table]]
     [metabase.models.app.graph :as app.graph]
     [metabase.models.collection :as collection]
@@ -101,8 +102,12 @@
                                   (throw (ex-info (i18n/tru "A scaffold-target was not provided for Card: {0}" (:name card))
                                                   {:status-code 400})))
                                 (let [card (api.card/create-card! (-> card
+                                                                      (replace-scaffold-targets accum)
                                                                       (assoc :collection_id collection-id)
-                                                                      (dissoc :scaffold-target)))]
+                                                                      (dissoc :scaffold-target)
+                                                                      (cond-> ;; card
+                                                                        (not (:dataset card))
+                                                                        (update-in [:dataset_query :query :source_table] #(str "card__" %)))))]
                                   (assoc accum (into ["scaffold-target-id"] scaffold-target) (:id card))))
                               {}
                               cards)]
@@ -146,7 +151,9 @@
                                       (pr-str (map :id tables)))
                             {:status-code 400})))
         table-id->table (m/index-by :id tables)
-        page-type-display {"list" {:name (i18n/tru "List")
+        page-type-display {"model" {:name (i18n/tru "Model")
+                                    :display "table"}
+                           "list" {:name (i18n/tru "List")
                                    :display "list"}
                            "detail" {:name (i18n/tru "Detail")
                                      :display "object"}}]
@@ -175,25 +182,32 @@
                                                (sort-by :priority)
                                                first
                                                :field-id)]
-                  page-type ["list" "detail"]]
-              {:scaffold-target ["card" table-id page-type]
-               :name (format "Query %s %s"
-                             (or (:display_name table) (:name table))
-                             (get-in page-type-display [page-type :name]))
-               :display (get-in page-type-display [page-type :display])
-               :visualization_settings (cond-> {}
-                                         (= page-type "list") (assoc "actions.bulk_enabled" false))
-               :dataset_query {:type "query"
-                               :database (:db_id table)
-                               :query (cond-> {:source_table table-id}
-                                        order-by-field-id (assoc :order_by [["desc", ["field", order-by-field-id, nil]]]))}})
+                  page-type ["model" "list" "detail"]]
+              (if (= "model" page-type)
+                {:scaffold-target ["card" table-id page-type]
+                 :name (or (:display_name table) (:name table))
+                 :display (get-in page-type-display [page-type :display])
+                 :visualization_settings {}
+                 :dataset true
+                 :dataset_query {:type "query"
+                                 :database (:db_id table)
+                                 :query (cond-> {:source_table table-id}
+                                          order-by-field-id (assoc :order_by [["desc", ["field", order-by-field-id, nil]]]))}}
+                {:scaffold-target ["card" table-id page-type]
+                 :name (format "Query %s %s"
+                               (or (:display_name table) (:name table))
+                               (get-in page-type-display [page-type :name]))
+                 :display (get-in page-type-display [page-type :display])
+                 :visualization_settings (cond-> {}
+                                           (= page-type "list") (assoc "actions.bulk_enabled" false))
+                 :dataset_query {:database mbql.s/saved-questions-virtual-database-id, :type "query", :query {:source_table ["scaffold-target-id" "card" table-id "model"]}}}))
      :pages (for [table-id table-ids
                   :let [table (get table-id->table table-id)
                         pks (filter (comp #(= :type/PK %) :semantic_type) (:fields table))
                         _ (when (not= 1 (count pks))
                             (throw (ex-info (i18n/tru "Table must have a single primary key: {0}" (:name table))
                                             {:status-code 400})))
-                        pk-field-id (:id (first pks))]
+                        pk-field-name (u/slugify (:name (first pks)))]
                   page-type ["list" "detail"]]
               (cond->
                {:name (format "%s %s"
@@ -214,13 +228,14 @@
                                                                                                              "id" (str "scaffold_" table-id)}}
                                                              "targetId" ["scaffold-target-id" "page" table-id "detail"]}}}
                                   {:size_y 1 :size_x 2 :row 0 :col 16
+                                   :card_id ["scaffold-target-id" "card" table-id "model"]
                                    :visualization_settings {"virtual_card" {"display" "action"}
                                                             "button.label" (i18n/tru "New"),
-                                                            "click_behavior" {"type" "action" "actionType" "insert" "tableId" table-id}}}]
+                                                            "action_slug" "insert"}}]
                                  [{:size_y 8 :size_x 18 :row 1 :col 0
                                    :parameter_mappings [{"parameter_id" (str "scaffold_" table-id)
                                                          "card_id" ["scaffold-target-id" "card" table-id "detail"]
-                                                         "target" ["dimension", ["field", pk-field-id, nil]]}]
+                                                         "target" ["variable", ["template-tag", pk-field-name]]}]
                                    :card_id ["scaffold-target-id" "card" table-id "detail"]
                                    :scaffold-target ["dashcard" table-id]}
                                   {:size_y 1 :size_x 2 :row 0 :col 0
@@ -228,14 +243,20 @@
                                                             "button.label" (i18n/tru "← Back to list"),
                                                             "click_behavior" {"type" "link" "linkType" "page" "targetId" ["scaffold-target-id" "page" table-id "list"]}}}
                                   {:size_y 1 :size_x 2 :row 0 :col 16
+                                   :card_id ["scaffold-target-id" "card" table-id "model"]
+                                   :parameter_mappings [{"parameter_id" (str "scaffold_" table-id)
+                                                         "target" ["variable", ["template-tag", pk-field-name]]}]
                                    :visualization_settings {"virtual_card" {"display" "action"}
                                                             "button.label" (i18n/tru "Delete"),
                                                             "button.variant" "danger"
-                                                            "click_behavior" {"type" "action" "actionType" "delete" "objectDetailDashCardId" ["scaffold-target-id" "dashcard" table-id]}}}
+                                                            "action_slug" "delete"}}
                                   {:size_y 1 :size_x 2 :row 0 :col 14
+                                   :card_id ["scaffold-target-id" "card" table-id "model"]
+                                   :parameter_mappings [{"parameter_id" (str "scaffold_" table-id)
+                                                         "target" ["variable", ["template-tag", pk-field-name]]}]
                                    :visualization_settings {"virtual_card" {"display" "action"}
                                                             "button.label" (i18n/tru "Edit"),
-                                                            "click_behavior" {"type" "action" "actionType" "update" "objectDetailDashCardId" ["scaffold-target-id" "dashcard" table-id]}}}])}
+                                                            "action_slug" "update"}}])}
                 (= "detail" page-type) (assoc :parameters [{:name "ID",
                                                             :slug "id",
                                                             :id (str "scaffold_" table-id),
diff --git a/src/metabase/api/dashboard.clj b/src/metabase/api/dashboard.clj
index 3e517c6a2a08817480ec6065e954f07bdc9bae09..504ee6a53d9f72ea260d62957c15e941d82342e0 100644
--- a/src/metabase/api/dashboard.clj
+++ b/src/metabase/api/dashboard.clj
@@ -697,24 +697,27 @@
     "value must be a parameter map with an 'id' key"))
 
 
-(def ParameterWithTarget
-  "Schema for a parameter map with an mbql `:target`."
-  (su/with-api-error-message
-    {:target   [s/Any]
-     s/Keyword s/Any}
-    "value must be a parameter map with a 'target' key"))
-
 ;;; ---------------------------------- Executing the action associated with a Dashcard -------------------------------
-
-(api/defendpoint POST "/:dashboard-id/dashcard/:dashcard-id/action/execute"
+(api/defendpoint GET "/:dashboard-id/dashcard/:dashcard-id/execute/:slug"
+  "Fetches the values for filling in execution parameters."
+  [dashboard-id dashcard-id slug]
+  {dashboard-id su/IntGreaterThanZero
+   dashcard-id su/IntGreaterThanZero
+   slug su/NonBlankString}
+  (throw (UnsupportedOperationException. "Not implemented")))
+
+(api/defendpoint POST "/:dashboard-id/dashcard/:dashcard-id/execute/:slug"
   "Execute the associated Action in the context of a `Dashboard` and `DashboardCard` that includes it.
 
    `parameters` should be the mapped dashboard parameters with values.
    `extra_parameters` should be the extra, user entered parameter values."
-  [dashboard-id dashcard-id :as {{:keys [parameters extra_parameters], :as _body} :body}]
-  {parameters (s/maybe [ParameterWithID])
-   extra_parameters (s/maybe [ParameterWithTarget])}
-  (actions.execution/execute-dashcard! dashboard-id dashcard-id parameters extra_parameters))
+  [dashboard-id dashcard-id slug :as {{:keys [parameters], :as _body} :body}]
+  {dashboard-id su/IntGreaterThanZero
+   dashcard-id su/IntGreaterThanZero
+   slug su/NonBlankString
+   parameters (s/maybe {s/Keyword s/Any})}
+  ;; Undo middleware string->keyword coercion
+  (actions.execution/execute-dashcard! dashboard-id dashcard-id slug (update-keys parameters name)))
 
 ;;; ---------------------------------- Running the query associated with a Dashcard ----------------------------------
 
diff --git a/src/metabase/api/model_action.clj b/src/metabase/api/model_action.clj
new file mode 100644
index 0000000000000000000000000000000000000000..0934eac670eee779ba3650062e80c56404de0310
--- /dev/null
+++ b/src/metabase/api/model_action.clj
@@ -0,0 +1,55 @@
+(ns metabase.api.model-action
+  (:require
+    [compojure.core :refer [DELETE GET POST PUT]]
+    [honeysql.core :as hsql]
+    [metabase.actions :as actions]
+    [metabase.api.common :as api]
+    [metabase.models :refer [Action Card HTTPAction ModelAction QueryAction]]
+    [metabase.util.schema :as su]
+    [schema.core :as s]
+    [toucan.db :as db]))
+
+(api/defendpoint GET "/"
+  "Endpoint to fetch actions for a model, must filter with card-id="
+  [card-id]
+  {card-id su/IntGreaterThanZero}
+  (db/query {:select [:model_action.*
+                      [(hsql/call :coalesce :report_card.name :http_action.name) :name]]
+             :from [ModelAction]
+             :left-join [QueryAction [:= :model_action.action_id :query_action.action_id]
+                         Card [:= :report_card.id :query_action.card_id]
+                         HTTPAction [:= :model_action.action_id :http_action.action_id]]
+             :where [:= :model_action.card_id card-id]
+             :order-by [:model_action.id]}))
+
+(api/defendpoint POST "/"
+  "Endpoint to associate an action with a model"
+  [:as {{:keys [card_id action_id slug requires_pk parameter_mappings visualization_settings] :as body} :body}]
+  {card_id su/IntGreaterThanZero
+   action_id (s/maybe su/IntGreaterThanZero)
+   slug su/NonBlankString
+   requires_pk s/Bool
+   parameter_mappings (s/maybe [su/ParameterMapping])
+   visualization_settings (s/maybe su/Map)}
+  (db/insert! ModelAction body))
+
+(api/defendpoint PUT "/:model-action-id"
+  "Endpoint to modify an action of a model"
+  [model-action-id :as {{:keys [action_id slug requires_pk parameter_mappings visualization_settings] :as body} :body}]
+  {action_id (s/maybe su/IntGreaterThanZero)
+   slug (s/maybe su/NonBlankString)
+   requires_pk (s/maybe s/Bool)
+   parameter_mappings (s/maybe [su/ParameterMapping])
+   visualization_settings (s/maybe su/Map)}
+  (db/update! ModelAction model-action-id (dissoc body :card_id))
+  api/generic-204-no-content)
+
+(api/defendpoint DELETE "/:model-action-id"
+  "Endpoint to delete an action"
+  [model-action-id]
+  (let [action_id (db/select-field :action_id ModelAction :id model-action-id)]
+    ;; Let cascade delete handle this
+    (db/delete! Action :id action_id)
+    api/generic-204-no-content))
+
+(api/define-routes actions/+check-actions-enabled)
diff --git a/src/metabase/api/routes.clj b/src/metabase/api/routes.clj
index 6a6620a7a6655e97dd3ee0f8499ceab1283294c4..e15d158d1281fc29e3c7cc72e4aef2cba2b0c478 100644
--- a/src/metabase/api/routes.clj
+++ b/src/metabase/api/routes.clj
@@ -21,6 +21,7 @@
             [metabase.api.ldap :as api.ldap]
             [metabase.api.login-history :as api.login-history]
             [metabase.api.metric :as api.metric]
+            [metabase.api.model-action :as api.model-action]
             [metabase.api.native-query-snippet :as api.native-query-snippet]
             [metabase.api.notify :as api.notify]
             [metabase.api.permissions :as api.permissions]
@@ -86,6 +87,7 @@
   (context "/login-history"        [] (+auth api.login-history/routes))
   (context "/premium-features"     [] (+auth api.premium-features/routes))
   (context "/metric"               [] (+auth api.metric/routes))
+  (context "/model-action"         [] (+auth api.model-action/routes))
   (context "/native-query-snippet" [] (+auth api.native-query-snippet/routes))
   (context "/notify"               [] (+apikey api.notify/routes))
   (context "/permissions"          [] (+auth api.permissions/routes))
diff --git a/src/metabase/cmd/copy.clj b/src/metabase/cmd/copy.clj
index f6bcc755243d59a342df9ee4f30526dbd50bace2..2e06bc50a2fb7cae977e66b0864fa7bda69b4320 100644
--- a/src/metabase/cmd/copy.clj
+++ b/src/metabase/cmd/copy.clj
@@ -30,6 +30,7 @@
                                      LoginHistory
                                      Metric
                                      MetricImportantField
+                                     ModelAction
                                      ModerationReview
                                      NativeQuerySnippet
                                      Permissions
@@ -99,6 +100,7 @@
    CardBookmark
    DashboardBookmark
    Emitter
+   ModelAction
    CollectionBookmark
    BookmarkOrdering
    DashboardCard
diff --git a/src/metabase/models.clj b/src/metabase/models.clj
index f078a63281ccc6f09666abf59918ba25e9f12a7e..12e0ad9dd55002d9e0bc0798e3a8adef3413028f 100644
--- a/src/metabase/models.clj
+++ b/src/metabase/models.clj
@@ -90,7 +90,7 @@
          view-log/keep-me)
 
 (p/import-vars
- [action Action HTTPAction QueryAction]
+ [action Action HTTPAction ModelAction QueryAction]
  [activity Activity]
  [app App]
  [bookmark CardBookmark]
diff --git a/src/metabase/models/action.clj b/src/metabase/models/action.clj
index a6822f19e49026f4e23ced2e2994517c7d70bf59..7eb1242eecaaab7a810d8bc5707f218a00ab41f2 100644
--- a/src/metabase/models/action.clj
+++ b/src/metabase/models/action.clj
@@ -1,15 +1,20 @@
 (ns metabase.models.action
   (:require [cheshire.core :as json]
+            [clojure.set :as set]
             [medley.core :as m]
             [metabase.models.interface :as mi]
+            [metabase.models.query :as query]
+            [metabase.models.serialization.hash :as serdes.hash]
             [metabase.util :as u]
             [metabase.util.encryption :as encryption]
             [toucan.db :as db]
+            [toucan.hydrate :refer [hydrate]]
             [toucan.models :as models]))
 
 (models/defmodel QueryAction :query_action)
 (models/defmodel HTTPAction :http_action)
 (models/defmodel Action :action)
+(models/defmodel ModelAction :model_action)
 
 (models/add-type! ::json-with-nested-parameters
   :in  (comp mi/json-in
@@ -52,6 +57,16 @@
   (merge Action-subtype-IModel-impl
          {:types (constantly {:template ::json-with-nested-parameters})}))
 
+(u/strict-extend #_{:clj-kondo/ignore [:metabase/disallow-class-or-type-on-model]} (class ModelAction)
+  models/IModel
+  (merge models/IModelDefaults
+         {:properties (constantly {:entity_id    true})
+          :types      (constantly {:parameter_mappings     :parameters-list
+                                   :visualization_settings :visualization-settings})})
+
+  serdes.hash/IdentityHashable
+  {:identity-hash-fields [:entity_id]})
+
 (defn insert!
   "Inserts an Action and related HTTPAction or QueryAction. Returns the action id."
   [action-data]
@@ -152,12 +167,83 @@
       (m/assoc-some card :action_id (get card-id->action-id (:id card))))
     cards))
 
+(defn unique-field-slugs?
+  "Makes sure that if `coll` is indexed by `index-by`, no keys will be in conflict."
+  [fields]
+  (empty? (m/filter-vals #(not= % 1) (frequencies (map (comp u/slugify :name) fields)))))
+
+(defn- implicit-action-parameters
+  [cards]
+  (let [card-id-by-table-id (into {}
+                                  (for [card cards
+                                        :let [{:keys [table-id]} (query/query->database-and-table-ids (:dataset_query card))]
+                                        :when table-id]
+                                    [table-id (:id card)]))
+        tables (when-let [table-ids (seq (keys card-id-by-table-id))]
+                 (hydrate (db/select 'Table :id [:in table-ids]) :fields))]
+    (into {}
+          (for [table tables
+                :let [fields (:fields table)]
+                ;; Skip tables for have conflicting slugified columns i.e. table has "name" and "NAME" columns.
+                :when (unique-field-slugs? fields)
+                :let [parameters (->> fields
+                                      (map (fn [field]
+                                             {:id (u/slugify (:name field))
+                                              :target [:variable [:template-tag (u/slugify (:name field))]]
+                                              :type (:base_type field)
+                                              ::pk? (isa? (:semantic_type field) :type/PK)})))]]
+            [(get card-id-by-table-id (:id table)) parameters]))))
+
+(defn merged-model-action
+  "Find model-actions given options and merge in the referenced action or generate implicit parameters for execution.
+   The goal is to generally hide the existence of model-action and be able to treat this merged information as an action.
+
+   Pass in known-models to save a second Card lookup."
+  [known-models & options]
+  (let [model-actions (apply db/select ModelAction {:order-by [:id]} options)
+        model-action-by-model-slug (m/index-by (juxt :card_id :slug)
+                                               model-actions)
+        actions-by-id (when-let [action-ids (not-empty (keep :action_id model-actions))]
+                        (m/index-by :id (select-actions :id [:in action-ids])))
+        model-ids-with-implicit-actions (set (map :card_id (remove :action_id model-actions)))
+        models-with-implicit-actions (if known-models
+                                       (->> known-models
+                                            (filter #(contains? model-ids-with-implicit-actions (:id %)))
+                                            distinct)
+                                       (when (seq model-ids-with-implicit-actions)
+                                         (db/select 'Card :id [:in model-ids-with-implicit-actions])))
+        parameters-by-model-id (when (seq models-with-implicit-actions)
+                                 (implicit-action-parameters models-with-implicit-actions))]
+    (for [model-action model-actions
+          :let [model-slug [(:card_id model-action) (:slug model-action)]
+                model-action (get model-action-by-model-slug model-slug)
+                action (get actions-by-id (:action_id model-action))
+                implicit-action (when-let [parameters (get parameters-by-model-id (:card_id model-action))]
+                                  {:parameters (cond->> parameters
+                                                 (not (:requires_pk model-action)) (remove #(::pk? %))
+                                                 (= "delete" (:slug model-action)) (filter #(::pk? %))
+                                                 :always (map #(dissoc % ::pk?)))
+                                   :type "implicit"})]]
+      (m/deep-merge (-> model-action
+                        (select-keys [:card_id :slug :action_id :visualization_settings :parameter_mappings])
+                        (set/rename-keys {:card_id :model_id}))
+                    implicit-action
+                    action))))
+
 (defn dashcard-action
   "Hydrates action from DashboardCard"
   {:batched-hydrate :dashcard/action}
   [dashcards]
-  (if-let [action-ids (not-empty (keep :action_id dashcards))]
-    (let [actions-by-id (m/index-by :id (select-actions :id [:in action-ids]))]
-      (for [dashcard dashcards]
-        (m/assoc-some dashcard :action (get actions-by-id (:action_id dashcard)))))
-    dashcards))
+  (let [model-slug-by-dashcard-id (->> dashcards
+                                       (keep (fn [dashcard]
+                                               (when-let [slug (get-in dashcard [:visualization_settings :action_slug])]
+                                                 [(:id dashcard) [(:card_id dashcard) slug]])))
+                                       (into {})
+                                       not-empty)
+        actions (when model-slug-by-dashcard-id
+                  (merged-model-action (map :card dashcards) [:card_id :slug] [:in (vals model-slug-by-dashcard-id)]))
+        action-by-model-slug (m/index-by (juxt :model_id :slug) actions)]
+    (for [dashcard dashcards]
+      (if-let [model-slug (get model-slug-by-dashcard-id (:id dashcard))]
+        (m/assoc-some dashcard :action (get action-by-model-slug model-slug))
+        dashcard))))
diff --git a/src/metabase/models/dashboard_card.clj b/src/metabase/models/dashboard_card.clj
index 56581c2374c794610e2a29aa8a13cb54e69fb1bf..d9ebacf95365dbd2ce804f1c6e4363cad6de5ddf 100644
--- a/src/metabase/models/dashboard_card.clj
+++ b/src/metabase/models/dashboard_card.clj
@@ -137,19 +137,24 @@
 (s/defn update-dashboard-card!
   "Update an existing DashboardCard` including all DashboardCardSeries.
    Returns the updated DashboardCard or throws an Exception."
-  [{:keys [id action_id parameter_mappings visualization_settings] :as dashboard-card} :- DashboardCardUpdates]
+  [{:keys [id card_id action_id parameter_mappings visualization_settings] :as dashboard-card} :- DashboardCardUpdates]
   (let [{:keys [size_x size_y row col series]} (merge {:series []} dashboard-card)]
     (db/transaction
      ;; update the dashcard itself (positional attributes)
      (when (and size_x size_y row col)
        (db/update-non-nil-keys! DashboardCard id
-                                :action_id              action_id
-                                :size_x                 size_x
-                                :size_y                 size_y
-                                :row                    row
-                                :col                    col
-                                :parameter_mappings     parameter_mappings
-                                :visualization_settings visualization_settings))
+                                (cond->
+                                  {:action_id              action_id
+                                   :size_x                 size_x
+                                   :size_y                 size_y
+                                   :row                    row
+                                   :col                    col
+                                   :parameter_mappings     parameter_mappings
+                                   :visualization_settings visualization_settings}
+                                  ;; Allow changing card for model_actions
+                                  ;; This is to preserve the existing behavior of questions and card_id
+                                  ;; I don't know why card_id couldn't be changed for questions though.
+                                  (:action_slug visualization_settings) (assoc :card_id card_id))))
      ;; update series (only if they changed)
      (when-not (= series (map :card_id (db/select [DashboardCardSeries :card_id]
                                                   :dashboardcard_id id
diff --git a/test/metabase/api/action_test.clj b/test/metabase/api/action_test.clj
index 7c83a7c8103407028e3d13b966654f89cad18c37..79ede267c6b9dfa471024bf9b86eb404d1cb6681 100644
--- a/test/metabase/api/action_test.clj
+++ b/test/metabase/api/action_test.clj
@@ -4,6 +4,7 @@
             [metabase.actions.test-util :as actions.test-util]
             [metabase.api.action :as api.action]
             [metabase.driver :as driver]
+            [metabase.models :refer [Card ModelAction]]
             [metabase.models.action :refer [Action]]
             [metabase.models.database :refer [Database]]
             [metabase.models.table :refer [Table]]
@@ -33,17 +34,29 @@
   (testing "GET /api/action"
     (actions.test-util/with-actions-enabled
       (actions.test-util/with-action [{:keys [action-id]} {}]
-        (let [response (mt/user-http-request :crowberto :get 200 "action")]
-          (is (schema= [{:id       su/IntGreaterThanZero
-                         s/Keyword s/Any}]
-                       response))
-          (let [action (some (fn [action]
-                               (when (= (:id action) action-id)
-                                 action))
-                             response)]
-            (testing "Should return Card dataset_query deserialized (#23201)"
-              (is (schema= ExpectedGetCardActionAPIResponse
-                           action)))))))))
+        (mt/with-temp* [Card [{card-id :id} {:dataset true :dataset_query (mt/mbql-query categories)}]
+                        ModelAction [_ {:card_id card-id :slug "custom" :action_id action-id}]
+                        ModelAction [_ {:card_id card-id :slug "insert"}]
+                        ModelAction [_ {:card_id card-id :slug "update" :requires_pk true}]
+                        ModelAction [_ {:card_id card-id :slug "delete" :requires_pk true}]]
+          (let [response (mt/user-http-request :crowberto :get 200 (str "action?model-id=" card-id))]
+            (is (partial= [{:slug "custom"
+                            :action_id action-id
+                            :parameters [{:id "id"} {:id "name"}]
+                            :card {:is_write true}
+                            :type "query"
+                            :name "Query Example"}
+                           {:slug "insert" :action_id nil :parameters [{:id "name"}] :type "implicit"}
+                           {:slug "update" :action_id nil :parameters [{:id "id"} {:id "name"}] :type "implicit"}
+                           {:slug "delete" :action_id nil :parameters [{:id "id"}] :type "implicit"}]
+                          response))
+            (let [action (some (fn [action]
+                                 (when (= (:id action) action-id)
+                                   action))
+                               response)]
+              (testing "Should return Card dataset_query deserialized (#23201)"
+                (is (schema= ExpectedGetCardActionAPIResponse
+                             action))))))))))
 
 (deftest get-action-test
   (testing "GET /api/action/:id"
@@ -297,55 +310,57 @@
             created-action (mt/user-http-request :crowberto :post 200 "action" initial-action)
             updated-action (merge initial-action {:name "New name"})
             action-path (str "action/" (:id created-action))]
-        (testing "Create"
-          (is (partial= initial-action created-action)))
-        (testing "Validate POST"
-          (testing "Required fields"
-            (is (partial= {:errors {:type "Only http actions are supported at this time."}}
-                          (mt/user-http-request :crowberto :post 400 "action" {:type "query"})))
-            (is (partial= {:errors {:name "value must be a string."}}
-                          (mt/user-http-request :crowberto :post 400 "action" {:type "http"})))
-            (is (partial= {:errors {:template "value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
-                          (mt/user-http-request :crowberto :post 400 "action" {:type "http" :name "test"}))))
-          (testing "Template needs method and url"
-            (is (partial= {:errors {:template "value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
-                          (mt/user-http-request :crowberto :post 400 "action" {:type "http" :name "Test" :template {}}))))
-          (testing "Template parameters should be well formed"
-            (is (partial= {:errors {:template "value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
-                          (mt/user-http-request :crowberto :post 400 "action" {:type "http"
-                                                                               :name "Test"
-                                                                               :template {:url "https://example.com"
-                                                                                          :method "GET"
-                                                                                          :parameters {}}}))))
-          (testing "Handles need to be valid jq"
-            (is (partial= {:errors {:response_handle "value may be nil, or if non-nil, must be a valid json-query"}}
-                          (mt/user-http-request :crowberto :post 400 "action" (assoc initial-action :response_handle "body"))))
-            (is (partial= {:errors {:error_handle "value may be nil, or if non-nil, must be a valid json-query"}}
-                          (mt/user-http-request :crowberto :post 400 "action" (assoc initial-action :error_handle "x"))))))
-        (testing "Update"
-          (is (partial= updated-action
-                        (mt/user-http-request :crowberto :put 200 action-path
-                                              {:name "New name" :type "http"}))))
-        (testing "Get"
-          (is (partial= updated-action
-                        (mt/user-http-request :crowberto :get 200 action-path)))
-          (is (partial= updated-action
-                        (last (mt/user-http-request :crowberto :get 200 "action")))))
-        (testing "Validate PUT"
-          (testing "Can't create or change http type"
-            (is (partial= {:errors {:type "Only http actions are supported at this time."}}
-                          (mt/user-http-request :crowberto :put 400 action-path {:type "query"}))))
-          (testing "Template needs method and url"
-            (is (partial= {:errors {:template "value may be nil, or if non-nil, value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
-                          (mt/user-http-request :crowberto :put 400 action-path {:type "http" :template {}}))))
-          (testing "Handles need to be valid jq"
-            (is (partial= {:errors {:response_handle "value may be nil, or if non-nil, must be a valid json-query"}}
-                          (mt/user-http-request :crowberto :put 400 action-path (assoc initial-action :response_handle "body"))))
-            (is (partial= {:errors {:error_handle "value may be nil, or if non-nil, must be a valid json-query"}}
-                          (mt/user-http-request :crowberto :put 400 action-path (assoc initial-action :error_handle "x"))))))
-        (testing "Delete"
-          (is (nil? (mt/user-http-request :crowberto :delete 204 action-path)))
-          (is (= "Not found." (mt/user-http-request :crowberto :get 404 action-path))))))))
+        (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                        ModelAction [_ {:card_id card-id :action_id (:id created-action) :slug "action"}]]
+          (testing "Create"
+            (is (partial= initial-action created-action)))
+          (testing "Validate POST"
+            (testing "Required fields"
+              (is (partial= {:errors {:type "Only http actions are supported at this time."}}
+                            (mt/user-http-request :crowberto :post 400 "action" {:type "query"})))
+              (is (partial= {:errors {:name "value must be a string."}}
+                            (mt/user-http-request :crowberto :post 400 "action" {:type "http"})))
+              (is (partial= {:errors {:template "value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
+                            (mt/user-http-request :crowberto :post 400 "action" {:type "http" :name "test"}))))
+            (testing "Template needs method and url"
+              (is (partial= {:errors {:template "value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
+                            (mt/user-http-request :crowberto :post 400 "action" {:type "http" :name "Test" :template {}}))))
+            (testing "Template parameters should be well formed"
+              (is (partial= {:errors {:template "value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
+                            (mt/user-http-request :crowberto :post 400 "action" {:type "http"
+                                                                                 :name "Test"
+                                                                                 :template {:url "https://example.com"
+                                                                                            :method "GET"
+                                                                                            :parameters {}}}))))
+            (testing "Handles need to be valid jq"
+              (is (partial= {:errors {:response_handle "value may be nil, or if non-nil, must be a valid json-query"}}
+                            (mt/user-http-request :crowberto :post 400 "action" (assoc initial-action :response_handle "body"))))
+              (is (partial= {:errors {:error_handle "value may be nil, or if non-nil, must be a valid json-query"}}
+                            (mt/user-http-request :crowberto :post 400 "action" (assoc initial-action :error_handle "x"))))))
+          (testing "Update"
+            (is (partial= updated-action
+                          (mt/user-http-request :crowberto :put 200 action-path
+                                                {:name "New name" :type "http"}))))
+          (testing "Get"
+            (is (partial= updated-action
+                          (mt/user-http-request :crowberto :get 200 action-path)))
+            (is (partial= updated-action
+                          (last (mt/user-http-request :crowberto :get 200 (str "action?model-id=" card-id))))))
+          (testing "Validate PUT"
+            (testing "Can't create or change http type"
+              (is (partial= {:errors {:type "Only http actions are supported at this time."}}
+                            (mt/user-http-request :crowberto :put 400 action-path {:type "query"}))))
+            (testing "Template needs method and url"
+              (is (partial= {:errors {:template "value may be nil, or if non-nil, value must be a map with schema: (\n  body (optional) : value may be nil, or if non-nil, value must be a string.\n  headers (optional) : value may be nil, or if non-nil, value must be a string.\n  parameter_mappings (optional) : value may be nil, or if non-nil, value must be a map.\n  parameters (optional) : value may be nil, or if non-nil, value must be an array. Each value must be a map.\n  method : value must be one of: `DELETE`, `GET`, `PATCH`, `POST`, `PUT`.\n  url : value must be a string.\n)"}}
+                            (mt/user-http-request :crowberto :put 400 action-path {:type "http" :template {}}))))
+            (testing "Handles need to be valid jq"
+              (is (partial= {:errors {:response_handle "value may be nil, or if non-nil, must be a valid json-query"}}
+                            (mt/user-http-request :crowberto :put 400 action-path (assoc initial-action :response_handle "body"))))
+              (is (partial= {:errors {:error_handle "value may be nil, or if non-nil, must be a valid json-query"}}
+                            (mt/user-http-request :crowberto :put 400 action-path (assoc initial-action :error_handle "x"))))))
+          (testing "Delete"
+            (is (nil? (mt/user-http-request :crowberto :delete 204 action-path)))
+            (is (= "Not found." (mt/user-http-request :crowberto :get 404 action-path)))))))))
 
 (deftest bulk-create-happy-path-test
   (testing "POST /api/action/bulk/create/:table-id"
diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj
index af542bd30bf6b21e88579d94fa054f755a618bca..0888b3b6ef866717fb1d94ff3b7100c1cad818d5 100644
--- a/test/metabase/api/dashboard_test.clj
+++ b/test/metabase/api/dashboard_test.clj
@@ -20,6 +20,7 @@
                                      Emitter
                                      Field
                                      FieldValues
+                                     ModelAction
                                      PermissionsGroup
                                      PermissionsGroupMembership
                                      Pulse
@@ -1049,6 +1050,28 @@
                 :updated_at             true}
                (remove-ids-and-booleanize-timestamps (dashboard-card/retrieve-dashboard-card dashcard-id-2))))))))
 
+(deftest update-action-cards-test
+  (actions.test-util/with-actions-enabled
+    (testing "PUT /api/dashboard/:id/cards"
+      ;; fetch a dashboard WITH a dashboard card on it
+      (mt/with-temp* [Dashboard     [{dashboard-id :id}]
+                      Card          [{model-id :id} {:dataset true}]
+                      Card          [{model-id-2 :id} {:dataset true}]
+                      ModelAction   [_ {:card_id model-id :slug "insert"}]
+                      ModelAction   [_ {:card_id model-id-2 :slug "insert"}]
+                      DashboardCard [action-card {:dashboard_id dashboard-id,
+                                                  :card_id model-id
+                                                  :visualization_settings {:action_slug "insert"}}]
+                      DashboardCard [question-card {:dashboard_id dashboard-id, :card_id model-id}]]
+        (with-dashboards-in-writeable-collection [dashboard-id]
+          (is (= {:status "ok"}
+                 (mt/user-http-request :rasta :put 200 (format "dashboard/%d/cards" dashboard-id)
+                                       {:cards [(assoc action-card :card_id model-id-2)
+                                                (assoc question-card :card_id model-id-2)]})))
+          ;; Only action card should change
+          (is (partial= [{:card_id model-id-2}
+                         {:card_id model-id}]
+                        (db/select DashboardCard :dashboard_id dashboard-id {:order-by [:id]}))))))))
 
 ;;; +----------------------------------------------------------------------------------------------------------------+
 ;;; |                                        GET /api/dashboard/:id/revisions                                        |
@@ -1857,19 +1880,16 @@
     (actions.test-util/with-actions-test-data-and-actions-enabled
       (actions.test-util/with-action [{:keys [action-id]} {}]
         (testing "Creating dashcard with action"
-          (mt/with-temp* [Dashboard [{dashboard-id :id}]]
-            (is (partial= {:action_id action-id}
+          (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                          ModelAction [_ {:card_id card-id :action_id action-id :slug "insert" :visualization_settings {:hello true}}]
+                          Dashboard [{dashboard-id :id}]]
+            (is (partial= {:visualization_settings {:action_slug "insert"}}
                           (mt/user-http-request :crowberto :post 200 (format "dashboard/%s/cards" dashboard-id)
-                                                {:size_x 1 :size_y 1 :row 1 :col 1 :action_id action-id})))
-            (is (partial= {:ordered_cards [{:action_id action-id :action {:id action-id}}]}
-                          (mt/user-http-request :crowberto :get 200 (format "dashboard/%s" dashboard-id))))))
-        (testing "Updating dashcard action"
-          (mt/with-temp* [Dashboard [{dashboard-id :id}]
-                          DashboardCard [dashcard {:dashboard_id dashboard-id}]]
-            (is (partial= {:status "ok"}
-                          (mt/user-http-request :crowberto :put 200 (format "dashboard/%s/cards" dashboard-id)
-                                                {:cards [(assoc dashcard :action_id action-id)]})))
-            (is (partial= {:ordered_cards [{:action_id action-id :action {:id action-id}}]}
+                                                {:size_x 1 :size_y 1 :row 1 :col 1 :cardId card-id
+                                                 :visualization_settings {:action_slug "insert"}})))
+            (is (partial= {:ordered_cards [{:action {:visualization_settings {:hello true :inline true}
+                                                     :parameters []
+                                                     :slug "insert"}}]}
                           (mt/user-http-request :crowberto :get 200 (format "dashboard/%s" dashboard-id))))))))))
 
 (deftest dashcard-query-action-execution-test
@@ -1877,117 +1897,183 @@
     (actions.test-util/with-actions-test-data-and-actions-enabled
       (actions.test-util/with-action [{:keys [action-id]} {}]
         (testing "Executing dashcard with action"
-          (mt/with-temp* [Dashboard [{dashboard-id :id}]
+          (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                          ModelAction [_ {:slug "custom" :card_id card-id :action_id action-id}]
+                          Dashboard [{dashboard-id :id}]
                           DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id
-                                                            :action_id action-id
-                                                            :parameter_mappings [{:parameter_id "my_id"
-                                                                                  :target [:variable [:template-tag "id"]]}]}]]
-            (let [execute-path (format "dashboard/%s/dashcard/%s/action/execute"
+                                                            :card_id card-id}]]
+            (let [execute-path (format "dashboard/%s/dashcard/%s/execute/custom"
                                        dashboard-id
                                        dashcard-id)]
               (testing "Dashcard parameter"
                 (is (partial= {:rows-affected 1}
                               (mt/user-http-request :crowberto :post 200 execute-path
-                                                    {:parameters [{:id "my_id" :type "id" :value 1}]})))
+                                                    {:parameters {"id" 1}})))
                 (is (= [1 "Shop"]
                        (mt/first-row
                          (mt/run-mbql-query categories {:filter [:= $id 1]})))))
               (testing "Extra target parameter"
                 (is (partial= {:rows-affected 1}
                               (mt/user-http-request :crowberto :post 200 execute-path
-                                                    {:parameters [{:id "my_id" :type "id" :value 1}]
-                                                     :extra_parameters [{:target [:variable [:template-tag "name"]]
-                                                                         :type "text"
-                                                                         :value "Bird"}]})))
+                                                    {:parameters {"id" 1 "name" "Bird"}})))
                 (is (= [1 "Bird Shop"]
                        (mt/first-row
                          (mt/run-mbql-query categories {:filter [:= $id 1]})))))
               (testing "Should affect 0 rows if id is out of range"
                 (is (= {:rows-affected 0}
                        (mt/user-http-request :crowberto :post 200 execute-path
-                                             {:parameters [{:id "my_id" :type  :number/= :value Integer/MAX_VALUE}]}))))
+                                             {:parameters {"id" Integer/MAX_VALUE}}))))
               (testing "Should 404 if bad dashcard-id"
                 (is (= "Not found."
-                       (mt/user-http-request :crowberto :post 404 (format "dashboard/%d/dashcard/%s/action/execute"
+                       (mt/user-http-request :crowberto :post 404 (format "dashboard/%d/dashcard/%s/execute/custom"
                                                                           dashboard-id
                                                                           Integer/MAX_VALUE)
                                              {}))))
               (testing "Missing parameter should fail gracefully"
                 (is (partial= {:message "Error executing Action: Error building query parameter map: Error determining value for parameter \"id\": You'll need to pick a value for 'ID' before this query can run."}
                               (mt/user-http-request :crowberto :post 500 execute-path
-                                                    {:parameters []}))))
+                                                    {:parameters {}}))))
               (testing "Sending an invalid number should fail gracefully"
 
                 (is (partial= {:message "Error executing Action: Error building query parameter map: Error determining value for parameter \"id\": Unparseable number: \"BAD\""}
                               (mt/user-http-request :crowberto :post 500 execute-path
-                                                    {:parameters [{:id "my_id" :type :number/= :value "BAD"}]})))))))))))
+                                                    {:parameters {"id" "BAD"}})))))))))))
 
 (deftest dashcard-http-action-execution-test
   (mt/test-drivers (mt/normal-drivers-with-feature :actions)
     (actions.test-util/with-actions-test-data-and-actions-enabled
       (actions.test-util/with-action [{:keys [action-id]} {:type :http}]
         (testing "Executing dashcard with action"
-          (mt/with-temp* [Dashboard [{dashboard-id :id}]
+          (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                          ModelAction [_ {:slug "custom" :card_id card-id :action_id action-id}]
+                          Dashboard [{dashboard-id :id}]
                           DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id
-                                                            :action_id action-id
-                                                            :parameter_mappings [{:parameter_id "my_id"
-                                                                                  :target [:template-tag "id"]}
-                                                                                 {:parameter_id "my_fail"
-                                                                                  :target [:template-tag "fail"]}]}]]
-            (let [execute-path (format "dashboard/%s/dashcard/%s/action/execute"
+                                                            :card_id card-id}]]
+            (let [execute-path (format "dashboard/%s/dashcard/%s/execute/custom"
                                        dashboard-id
                                        dashcard-id)]
               (testing "Should be able to execute an emitter"
                 (is (= {:the_parameter 1}
                        (mt/user-http-request :crowberto :post 200 execute-path
-                                             {:parameters [{:id "my_id" :type :number/= :value 1}]}))))
+                                             {:parameters {"id" 1}}))))
               (testing "Should handle errors"
                 (is (= {:remote-status 400}
                        (mt/user-http-request :crowberto :post 400 execute-path
-                                             {:parameters [{:id "my_id" :type :number/= :value 1}
-                                                           {:id "my_fail" :type :text :value "true"}]}))))
+                                             {:parameters {"id" 1 "fail" "true"}}))))
               (testing "Extra parameter should fail gracefully"
-                (is (partial= {:message "No parameter mapping found for parameter \"extra\". Found: #{\"my_id\" \"my_fail\"}"}
+                (is (partial= {:message "No destination parameter found for id \"extra\". Found: #{\"id\" \"fail\"}"}
                               (mt/user-http-request :crowberto :post 400 execute-path
-                                                    {:parameters [{:id "extra" :type :number/= :value 1}]}))))
+                                                    {:parameters {"extra" 1}}))))
               (testing "Missing parameter should fail gracefully"
                 (is (partial= {:message "Problem building request: Cannot call the service: missing required parameters: #{\"id\"}"}
                               (mt/user-http-request :crowberto :post 500 execute-path
-                                                    {:parameters []}))))
+                                                    {:parameters {}}))))
               (testing "Sending an invalid number should fail gracefully"
                 (is (str/starts-with? (:message (mt/user-http-request :crowberto :post 500 execute-path
-                                                                      {:parameters [{:id "my_id" :type :number/= :value "BAD"}]}))
+                                                                      {:parameters {"id" "BAD"}}))
                                       "Problem building request:"))))))))))
 
+(deftest dashcard-implicit-action-execution-test
+  (mt/test-drivers (mt/normal-drivers-with-feature :actions)
+    (actions.test-util/with-actions-test-data-and-actions-enabled
+      (testing "Executing dashcard insert"
+        (mt/with-temp* [Card [{card-id :id} {:dataset true :dataset_query (mt/mbql-query categories)}]
+                        ModelAction [_ {:slug "insert" :card_id card-id :requires_pk false}]
+                        Dashboard [{dashboard-id :id}]
+                        DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id
+                                                          :card_id card-id
+                                                          :visualization_settings {:action_slug "insert"}}]]
+          (let [execute-path (format "dashboard/%s/dashcard/%s/execute/insert" dashboard-id dashcard-id)
+                new-row (-> (mt/user-http-request :crowberto :post 200 execute-path
+                                                  {:parameters {"name" "Birds"}})
+                            :created-row
+                            (update-keys (comp keyword str/lower-case name)))]
+            (testing "Should be able to insert"
+              (is (pos? (:id new-row)))
+              (is (partial= {:name "Birds"}
+                            new-row)))
+            (testing "Extra parameter should fail gracefully"
+              (is (partial= {:message "No destination parameter found for #{\"extra\"}. Found: #{\"id\" \"name\"}"}
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {"extra" 1}}))))
+            (testing "Missing other parameters should fail gracefully"
+              (is (partial= "Implicit parameters must be provided."
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {}})))))))
+      (testing "Executing dashcard update"
+        (mt/with-temp* [Card [{card-id :id} {:dataset true :dataset_query (mt/mbql-query categories)}]
+                        ModelAction [_ {:slug "update" :card_id card-id :requires_pk true}]
+                        Dashboard [{dashboard-id :id}]
+                        DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id
+                                                          :card_id card-id
+                                                          :visualization_settings {:action_slug "update"}}]]
+          (let [execute-path (format "dashboard/%s/dashcard/%s/execute/update" dashboard-id dashcard-id)]
+            (testing "Should be able to update"
+              (is (= {:rows-updated [1]}
+                     (mt/user-http-request :crowberto :post 200 execute-path
+                                           {:parameters {"id" 1 "name" "Birds"}}))))
+            (testing "Extra parameter should fail gracefully"
+              (is (partial= {:message "No destination parameter found for #{\"extra\"}. Found: #{\"id\" \"name\"}"}
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {"extra" 1}}))))
+            (testing "Missing pk parameter should fail gracefully"
+              (is (partial= "Missing primary key parameter: \"id\""
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {"name" "Birds"}}))))
+            (testing "Missing other parameters should fail gracefully"
+              (is (partial= "Implicit parameters must be provided."
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {"id" 1}})))))))
+      (testing "Executing dashcard delete"
+        (mt/with-temp* [Card [{card-id :id} {:dataset true :dataset_query (mt/mbql-query categories)}]
+                        ModelAction [_ {:slug "delete" :card_id card-id :requires_pk true}]
+                        Dashboard [{dashboard-id :id}]
+                        DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id
+                                                          :card_id card-id
+                                                          :visualization_settings {:action_slug "delete"}}]]
+          (let [execute-path (format "dashboard/%s/dashcard/%s/execute/delete" dashboard-id dashcard-id)]
+            (testing "Should be able to delete"
+              (is (= {:rows-deleted [1]}
+                     (mt/user-http-request :crowberto :post 200 execute-path
+                                           {:parameters {"id" 1}}))))
+            (testing "Extra parameter should fail gracefully"
+              (is (partial= {:message "No destination parameter found for #{\"extra\"}. Found: #{\"id\" \"name\"}"}
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {"extra" 1}}))))
+            (testing "Missing pk parameter should fail gracefully"
+              (is (partial= "Missing primary key parameter: \"id\""
+                            (mt/user-http-request :crowberto :post 400 execute-path
+                                                  {:parameters {"name" "Birds"}}))))))))))
+
 (deftest dashcard-action-execution-auth-test
   (mt/with-temp-copy-of-db
     (actions.test-util/with-actions-test-data
       (actions.test-util/with-action [{:keys [action-id]} {}]
         (testing "Executing dashcard with action"
-          (mt/with-temp* [Dashboard [{dashboard-id :id}]
+          (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                          ModelAction [_ {:slug "custom" :card_id card-id :action_id action-id}]
+                          Dashboard [{dashboard-id :id}]
                           DashboardCard [{dashcard-id :id} {:dashboard_id dashboard-id
-                                                            :action_id action-id
-                                                            :parameter_mappings [{:parameter_id "my_id"
-                                                                                  :target [:variable [:template-tag "id"]]}]}]]
-            (let [execute-path (format "dashboard/%s/dashcard/%s/action/execute"
+                                                            :card_id card-id}]]
+            (let [execute-path (format "dashboard/%s/dashcard/%s/execute/custom"
                                        dashboard-id
                                        dashcard-id)]
               (testing "Without actions enabled"
                 (is (= "Actions are not enabled."
                        (mt/user-http-request :crowberto :post 400 execute-path
-                                             {:parameters [{:id "my_id" :type :number/= :value 1}]}))))
+                                             {:parameters {"id" 1}}))))
               (testing "Without execute rights on the DB"
                 (actions.test-util/with-actions-enabled
                   (is (= "You don't have permissions to do that."
                          (mt/user-http-request :rasta :post 403 execute-path
-                                               {:parameters [{:id "my_id" :type :number/= :value 1}]})))))
+                                               {:parameters {"id" 1}})))))
               (testing "With execute rights on the DB"
                 (perms/update-global-execution-permission! (:id (perms-group/all-users)) :all)
                 (try
                   (actions.test-util/with-actions-enabled
                     (is (= {:rows-affected 1}
                            (mt/user-http-request :rasta :post 200 execute-path
-                                                 {:parameters [{:id "my_id" :type :number/= :value 1}]}))))
+                                                 {:parameters {"id" 1}}))))
                   (finally
                     (perms/update-global-execution-permission! (:id (perms-group/all-users)) :none)))))))))))
 
@@ -2004,12 +2090,13 @@
           (actions.test-util/with-action [{:keys [action-id]} {}]
             (testing "Executing dashcard with action"
               (mt/with-temp* [Dashboard [{dashboard-id :id}]
+                              Card [{card-id :id} {:dataset true}]
+                              ModelAction [_ {:action_id action-id :card_id card-id :slug "custom"}]
                               DashboardCard [{dashcard-id :id}
                                              {:dashboard_id dashboard-id
-                                              :action_id action-id
-                                              :parameter_mappings [{:parameter_id "my_id"
-                                                                    :target [:variable [:template-tag "id"]]}]}]]
-                (let [execute-path (format "dashboard/%s/dashcard/%s/action/execute"
+                                              :card_id card-id
+                                              :visualization_settings {:action_slug "custom"}}]]
+                (let [execute-path (format "dashboard/%s/dashcard/%s/execute/custom"
                                            dashboard-id
                                            dashcard-id)]
                   (testing "with :advanced-permissions feature flag"
@@ -2020,7 +2107,7 @@
                                                                        :group_id group-id}]]
                           (is (= "You don't have permissions to do that."
                                  (mt/user-http-request :rasta :post 403 execute-path
-                                                       {:parameters [{:id "my_id" :type :number/= :value 1}]}))
+                                                       {:parameters {"id" 1}}))
                               "Execution permission should be required")
 
                           (mt/user-http-request
@@ -2031,7 +2118,7 @@
                               "Should be able to set execution permission")
                           (is (= {:rows-affected 1}
                                  (mt/user-http-request :rasta :post 200 execute-path
-                                                       {:parameters [{:id "my_id" :type :number/= :value 1}]}))
+                                                       {:parameters {"id" 1}}))
                               "Execution and data permissions should be enough")
 
                           (perms/update-data-perms-graph! [group-id (mt/id) :data]
@@ -2040,5 +2127,5 @@
                                                           {:schemas :block})
                           (is (= "You don't have permissions to do that."
                                  (mt/user-http-request :rasta :post 403 execute-path
-                                                       {:parameters [{:id "my_id" :type :number/= :value 1}]}))
+                                                       {:parameters {"id" 1}}))
                               "Data permissions should be required"))))))))))))))
diff --git a/test/metabase/api/model_action_test.clj b/test/metabase/api/model_action_test.clj
new file mode 100644
index 0000000000000000000000000000000000000000..67fd1ae1f706f3a62021c17faa30e1e1e6dcd8ab
--- /dev/null
+++ b/test/metabase/api/model_action_test.clj
@@ -0,0 +1,53 @@
+(ns metabase.api.model-action-test
+  (:require
+    [clojure.test :refer [deftest is testing]]
+    [metabase.actions.test-util :as actions.test-util]
+    [metabase.models :refer [Card ModelAction]]
+    [metabase.test :as mt]
+    [toucan.db :as db]))
+
+(deftest get-test
+  (actions.test-util/with-actions-enabled
+    (actions.test-util/with-action [{:keys [action-id]} {}]
+      (actions.test-util/with-action [{http-action-id :action-id} {:name "HTTP Example"}]
+        (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                        ModelAction [_ {:card_id card-id :action_id action-id :slug "query"}]
+                        ModelAction [_ {:card_id card-id :action_id http-action-id :slug "http"}]
+                        ModelAction [_ {:card_id card-id :slug "implicit"}]]
+          (let [response (mt/user-http-request :crowberto :get 200 (str "model-action?card-id=" card-id))]
+            (is (partial=
+                  [{:slug "query" :name "Query Example"}
+                   {:slug "http" :name "HTTP Example"}
+                   {:slug "implicit" :name nil}]
+                  response))))))))
+
+(deftest post-test
+  (actions.test-util/with-actions-enabled
+    (mt/with-temp* [Card [{card-id :id} {:dataset true}]]
+      (testing "With implicit action"
+        (let [response (mt/user-http-request :crowberto :post 200 "model-action"
+                                             {:card_id card-id
+                                              :requires_pk false
+                                              :slug "insert"})]
+          (is (partial=
+                {:slug "insert"}
+                response))))
+      (testing "With custom action"
+        (actions.test-util/with-action [{:keys [action-id]} {}]
+          (let [response (mt/user-http-request :crowberto :post 200 "model-action"
+                                               {:card_id card-id
+                                                :action_id action-id
+                                                :requires_pk false
+                                                :slug "custom"})]
+            (is (partial=
+                  {:slug "custom"}
+                  response))))))))
+
+(deftest put-test
+  (actions.test-util/with-actions-enabled
+    (actions.test-util/with-action [{:keys [action-id]} {}]
+      (mt/with-temp* [Card [{card-id :id} {:dataset true}]
+                      ModelAction [{model-action-id :id} {:card_id card-id :slug "implicit"}]]
+        (mt/user-http-request :crowberto :put 204 (str "model-action/" model-action-id)
+                              {:action_id action-id})
+        (is (= action-id (db/select-one-field :action_id ModelAction :id model-action-id)))))))