diff --git a/cmd/joj3/conf/conf.go b/cmd/joj3/conf/conf.go
index 24450ca..7c3af10 100644
--- a/cmd/joj3/conf/conf.go
+++ b/cmd/joj3/conf/conf.go
@@ -35,6 +35,12 @@ type ConfStage struct {
 	}
 }
 
+type ConfGroup struct {
+	Name           string
+	MaxCount       int
+	TimePeriodHour int
+}
+
 type Conf struct {
 	Name                string `default:"unknown"`
 	LogPath             string `default:""`
@@ -57,7 +63,8 @@ type Conf struct {
 		SkipScoreboard        bool   `default:"false"`
 		SkipFailedTable       bool   `default:"false"`
 		SubmitterInIssueTitle bool   `default:"true"`
-		MaxTotalScore         int    `default:"-1"` // TODO: remove me
+		Groups                []ConfGroup
+		MaxTotalScore         int `default:"-1"` // TODO: remove me
 	}
 	// TODO: remove the following backward compatibility fields
 	SandboxExecServer string `default:"localhost:5051"`
diff --git a/cmd/joj3/main.go b/cmd/joj3/main.go
index 090a3ed..08b76e0 100644
--- a/cmd/joj3/main.go
+++ b/cmd/joj3/main.go
@@ -106,6 +106,14 @@ func mainImpl() (err error) {
 		return err
 	}
 	groups := conf.MatchGroups(confObj, conventionalCommit)
+	if len(confObj.Teapot.Groups) != 0 {
+		if err = teapot.Check(confObj); err != nil {
+			slog.Error("teapot check", "error", err)
+			return err
+		}
+	} else {
+		slog.Info("teapot check disabled")
+	}
 	stageResults, forceQuitStageName, err = stage.Run(confObj, groups)
 	if err != nil {
 		slog.Error("stage run", "error", err)
diff --git a/cmd/joj3/teapot/check.go b/cmd/joj3/teapot/check.go
new file mode 100644
index 0000000..9efbc13
--- /dev/null
+++ b/cmd/joj3/teapot/check.go
@@ -0,0 +1,43 @@
+package teapot
+
+import (
+	"fmt"
+	"log/slog"
+	"os"
+	"strings"
+
+	"github.com/joint-online-judge/JOJ3/cmd/joj3/conf"
+	"github.com/joint-online-judge/JOJ3/cmd/joj3/env"
+)
+
+func Check(conf *conf.Conf) (err error) {
+	os.Setenv("LOG_FILE_PATH", conf.Teapot.LogPath)
+	os.Setenv("_TYPER_STANDARD_TRACEBACK", "1")
+	if env.Attr.Actor == "" ||
+		env.Attr.Repository == "" ||
+		strings.Count(env.Attr.Repository, "/") != 1 {
+		slog.Error("teapot env not set")
+		err = fmt.Errorf("teapot env not set")
+		return
+	}
+	repoParts := strings.Split(env.Attr.Repository, "/")
+	repoName := repoParts[1]
+	var formattedGroups []string
+	for _, group := range conf.Teapot.Groups {
+		groupConfig := fmt.Sprintf("%s=%d:%d",
+			group.Name, group.MaxCount, group.TimePeriodHour)
+		formattedGroups = append(formattedGroups, groupConfig)
+	}
+	args := []string{
+		"joj3-check", conf.Teapot.EnvFilePath,
+		env.Attr.Actor, conf.Teapot.GradingRepoName, repoName,
+		conf.Teapot.ScoreboardPath, conf.Name,
+		"--group-config", strings.Join(formattedGroups, ","),
+	}
+	_, err = runCommand(args)
+	if err != nil {
+		slog.Error("teapot check exec", "error", err)
+		return
+	}
+	return
+}
diff --git a/cmd/joj3/teapot/exec.go b/cmd/joj3/teapot/exec.go
new file mode 100644
index 0000000..c89d71d
--- /dev/null
+++ b/cmd/joj3/teapot/exec.go
@@ -0,0 +1,57 @@
+package teapot
+
+import (
+	"bufio"
+	"bytes"
+	"log/slog"
+	"os/exec"
+	"regexp"
+	"sync"
+)
+
+func runCommand(args []string) (
+	stdoutBuf *bytes.Buffer, err error,
+) {
+	re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
+	cmd := exec.Command("joint-teapot", args...) // #nosec G204
+	stdoutBuf = new(bytes.Buffer)
+	cmd.Stdout = stdoutBuf
+	stderr, err := cmd.StderrPipe()
+	if err != nil {
+		slog.Error("stderr pipe", "error", err)
+		return
+	}
+	var wg sync.WaitGroup
+	wg.Add(1)
+	scanner := bufio.NewScanner(stderr)
+	go func() {
+		for scanner.Scan() {
+			text := re.ReplaceAllString(scanner.Text(), "")
+			if text == "" {
+				continue
+			}
+			slog.Info("joint-teapot", "stderr", text)
+		}
+		wg.Done()
+		if scanner.Err() != nil {
+			slog.Error("stderr scanner", "error", scanner.Err())
+		}
+	}()
+	if err = cmd.Start(); err != nil {
+		slog.Error("cmd start", "error", err)
+		return
+	}
+	wg.Wait()
+	if err = cmd.Wait(); err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			exitCode := exitErr.ExitCode()
+			slog.Error("cmd completed with non-zero exit code",
+				"error", err,
+				"exitCode", exitCode)
+		} else {
+			slog.Error("cmd wait", "error", err)
+		}
+		return
+	}
+	return
+}
diff --git a/cmd/joj3/teapot/run.go b/cmd/joj3/teapot/run.go
index dc1dfcc..dadadf2 100644
--- a/cmd/joj3/teapot/run.go
+++ b/cmd/joj3/teapot/run.go
@@ -1,17 +1,12 @@
 package teapot
 
 import (
-	"bufio"
-	"bytes"
 	"encoding/json"
 	"fmt"
 	"log/slog"
 	"os"
-	"os/exec"
-	"regexp"
 	"strconv"
 	"strings"
-	"sync"
 
 	"github.com/joint-online-judge/JOJ3/cmd/joj3/conf"
 	"github.com/joint-online-judge/JOJ3/cmd/joj3/env"
@@ -54,8 +49,7 @@ func Run(conf *conf.Conf, groups []string) (
 	if conf.Teapot.SubmitterInIssueTitle {
 		submitterInIssueTitleArg = "--submitter-in-issue-title"
 	}
-	re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
-	cmd := exec.Command("joint-teapot",
+	args := []string{
 		"joj3-all", conf.Teapot.EnvFilePath, conf.Stage.OutputPath,
 		env.Attr.Actor, conf.Teapot.GradingRepoName, repoName,
 		env.Attr.RunNumber, conf.Teapot.ScoreboardPath,
@@ -65,44 +59,10 @@ func Run(conf *conf.Conf, groups []string) (
 		"--max-total-score", strconv.Itoa(conf.MaxTotalScore),
 		skipIssueArg, skipScoreboardArg,
 		skipFailedTableArg, submitterInIssueTitleArg,
-	) // #nosec G204
-	stdoutBuf := new(bytes.Buffer)
-	cmd.Stdout = stdoutBuf
-	stderr, err := cmd.StderrPipe()
+	}
+	stdoutBuf, err := runCommand(args)
 	if err != nil {
-		slog.Error("stderr pipe", "error", err)
-		return
-	}
-	var wg sync.WaitGroup
-	wg.Add(1)
-	scanner := bufio.NewScanner(stderr)
-	go func() {
-		for scanner.Scan() {
-			text := re.ReplaceAllString(scanner.Text(), "")
-			if text == "" {
-				continue
-			}
-			slog.Info("joint-teapot", "stderr", text)
-		}
-		wg.Done()
-		if scanner.Err() != nil {
-			slog.Error("stderr scanner", "error", scanner.Err())
-		}
-	}()
-	if err = cmd.Start(); err != nil {
-		slog.Error("cmd start", "error", err)
-		return
-	}
-	wg.Wait()
-	if err = cmd.Wait(); err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok {
-			exitCode := exitErr.ExitCode()
-			slog.Error("cmd completed with non-zero exit code",
-				"error", err,
-				"exitCode", exitCode)
-		} else {
-			slog.Error("cmd wait", "error", err)
-		}
+		slog.Error("teapot run exec", "error", err)
 		return
 	}
 	if json.Unmarshal(stdoutBuf.Bytes(), &teapotResult) != nil {