diff --git a/cmd/joj3/main.go b/cmd/joj3/main.go
index 0fd6385..d756b2a 100644
--- a/cmd/joj3/main.go
+++ b/cmd/joj3/main.go
@@ -1,170 +1,19 @@
 package main
 
 import (
-	"encoding/json"
-	"flag"
-	"fmt"
 	"log/slog"
-	"os"
 
-	"github.com/joint-online-judge/JOJ3/internal/executors"
-	_ "github.com/joint-online-judge/JOJ3/internal/parsers"
-	"github.com/joint-online-judge/JOJ3/internal/stage"
-
-	"github.com/go-git/go-git/v5"
-	"github.com/jinzhu/copier"
+	stagerunner "github.com/joint-online-judge/JOJ3/cmd/joj3/stage-runner"
+	teapotcaller "github.com/joint-online-judge/JOJ3/cmd/joj3/teapot-caller"
 )
 
-func getCommitMsg() (msg string, err error) {
-	r, err := git.PlainOpen(".")
-	if err != nil {
-		return
-	}
-	ref, err := r.Head()
-	if err != nil {
-		return
-	}
-	commit, err := r.CommitObject(ref.Hash())
-	if err != nil {
-		return
-	}
-	msg = commit.Message
-	return
-}
-
-func generateStages(conf Conf, group string) ([]stage.Stage, error) {
-	stages := []stage.Stage{}
-	existNames := map[string]bool{}
-	for _, s := range conf.Stages {
-		if s.Group != "" && group != s.Group {
-			continue
-		}
-		_, ok := existNames[s.Name] // check for existence
-		if ok {
-			continue
-		}
-		existNames[s.Name] = true
-		var cmds []stage.Cmd
-		defaultCmd := s.Executor.With.Default
-		for _, optionalCmd := range s.Executor.With.Cases {
-			cmd := s.Executor.With.Default
-			err := copier.Copy(&cmd, &optionalCmd)
-			if err != nil {
-				slog.Error("generate stages", "error", err)
-				return stages, err
-			}
-			// since these 3 values are pointers, copier will always copy
-			// them, so we need to check them manually
-			if defaultCmd.Stdin != nil && optionalCmd.Stdin == nil {
-				cmd.Stdin = defaultCmd.Stdin
-			}
-			if defaultCmd.Stdout != nil && optionalCmd.Stdout == nil {
-				cmd.Stdout = defaultCmd.Stdout
-			}
-			if defaultCmd.Stderr != nil && optionalCmd.Stderr == nil {
-				cmd.Stderr = defaultCmd.Stderr
-			}
-			cmds = append(cmds, cmd)
-		}
-		if len(s.Executor.With.Cases) == 0 {
-			cmds = []stage.Cmd{defaultCmd}
-		}
-		stages = append(stages, stage.Stage{
-			Name:         s.Name,
-			ExecutorName: s.Executor.Name,
-			ExecutorCmds: cmds,
-			ParserName:   s.Parser.Name,
-			ParserConf:   s.Parser.With,
-		})
-	}
-	slog.Debug("stages generated", "stages", stages)
-	return stages, nil
-}
-
-func outputResult(outputPath string, results []stage.StageResult) error {
-	slog.Info("output result start", "path", outputPath)
-	slog.Debug("output result start", "path", outputPath, "results", results)
-	content, err := json.Marshal(results)
-	if err != nil {
-		return err
-	}
-	return os.WriteFile(outputPath,
-		append(content, []byte("\n")...), 0o600)
-}
-
-var (
-	confRoot    string
-	confName    string
-	msg         string
-	showVersion *bool
-	Version     string = "debug"
-)
-
-func init() {
-	flag.StringVar(&confRoot, "conf-root", ".", "root path for all config files")
-	flag.StringVar(&confName, "conf-name", "conf.json", "filename for config files")
-	flag.StringVar(&msg, "msg", "", "message to trigger the running, leave empty to use git commit message on HEAD")
-	showVersion = flag.Bool("version", false, "print current version")
-}
-
-func mainImpl() error {
-	for _, e := range os.Environ() {
-		fmt.Println(e)
-	}
-	if err := setupSlog(""); err != nil { // before conf is loaded
-		return err
-	}
-	flag.Parse()
-	if *showVersion {
-		fmt.Println(Version)
-		return nil
-	}
-	slog.Info("start joj3", "version", Version)
-	if msg == "" {
-		var err error
-		msg, err = getCommitMsg()
-		if err != nil {
-			slog.Error("get commit msg", "error", err)
-			return err
-		}
-	}
-	conf, group, err := parseMsg(confRoot, confName, msg)
-	if err != nil {
-		slog.Error("parse msg", "error", err)
-		validScopes, scopeErr := listValidScopes(
-			confRoot, confName, msg)
-		if scopeErr != nil {
-			slog.Error("list valid scopes", "error", scopeErr)
-			return scopeErr
-		}
-		slog.Info("hint: valid scopes in commit message", "scopes", validScopes)
-		return err
-	}
-	if err := setupSlog(conf.LogPath); err != nil { // after conf is loaded
-		return err
-	}
-	executors.InitWithConf(conf.SandboxExecServer, conf.SandboxToken)
-	stages, err := generateStages(conf, group)
-	if err != nil {
-		slog.Error("generate stages", "error", err)
-		return err
-	}
-	defer stage.Cleanup()
-	results, err := stage.Run(stages)
-	if err != nil {
-		slog.Error("run stages", "error", err)
-		return err
-	}
-	if err := outputResult(conf.OutputPath, results); err != nil {
-		slog.Error("output result", "error", err)
-		return err
-	}
-	return nil
-}
-
 func main() {
-	if err := mainImpl(); err != nil {
-		slog.Error("main exit", "error", err)
-		os.Exit(1)
+	// TODO: call stage-runner
+	if err := stagerunner.Run(); err != nil {
+		slog.Error("stage runner error", "error", err)
+	}
+	// TODO: call joint-teapot
+	if err := teapotcaller.Run(); err != nil {
+		slog.Error("teapot caller error", "error", err)
 	}
 }
diff --git a/cmd/joj3/conf.go b/cmd/joj3/stage-runner/conf.go
similarity index 99%
rename from cmd/joj3/conf.go
rename to cmd/joj3/stage-runner/conf.go
index 6e63847..09f84cc 100644
--- a/cmd/joj3/conf.go
+++ b/cmd/joj3/stage-runner/conf.go
@@ -1,4 +1,4 @@
-package main
+package stagerunner
 
 import (
 	"fmt"
diff --git a/cmd/joj3/conf_test.go b/cmd/joj3/stage-runner/conf_test.go
similarity index 99%
rename from cmd/joj3/conf_test.go
rename to cmd/joj3/stage-runner/conf_test.go
index 0906cc3..75631f6 100644
--- a/cmd/joj3/conf_test.go
+++ b/cmd/joj3/stage-runner/conf_test.go
@@ -1,4 +1,4 @@
-package main
+package stagerunner
 
 import (
 	"reflect"
diff --git a/cmd/joj3/log.go b/cmd/joj3/stage-runner/log.go
similarity index 98%
rename from cmd/joj3/log.go
rename to cmd/joj3/stage-runner/log.go
index bdeccc4..7c671b2 100644
--- a/cmd/joj3/log.go
+++ b/cmd/joj3/stage-runner/log.go
@@ -1,4 +1,4 @@
-package main
+package stagerunner
 
 import (
 	"context"
diff --git a/cmd/joj3/stage-runner/main.go b/cmd/joj3/stage-runner/main.go
new file mode 100644
index 0000000..f4b3a9b
--- /dev/null
+++ b/cmd/joj3/stage-runner/main.go
@@ -0,0 +1,160 @@
+package stagerunner
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"log/slog"
+	"os"
+
+	"github.com/joint-online-judge/JOJ3/internal/executors"
+	_ "github.com/joint-online-judge/JOJ3/internal/parsers"
+	"github.com/joint-online-judge/JOJ3/internal/stage"
+
+	"github.com/go-git/go-git/v5"
+	"github.com/jinzhu/copier"
+)
+
+func getCommitMsg() (msg string, err error) {
+	r, err := git.PlainOpen(".")
+	if err != nil {
+		return
+	}
+	ref, err := r.Head()
+	if err != nil {
+		return
+	}
+	commit, err := r.CommitObject(ref.Hash())
+	if err != nil {
+		return
+	}
+	msg = commit.Message
+	return
+}
+
+func generateStages(conf Conf, group string) ([]stage.Stage, error) {
+	stages := []stage.Stage{}
+	existNames := map[string]bool{}
+	for _, s := range conf.Stages {
+		if s.Group != "" && group != s.Group {
+			continue
+		}
+		_, ok := existNames[s.Name] // check for existence
+		if ok {
+			continue
+		}
+		existNames[s.Name] = true
+		var cmds []stage.Cmd
+		defaultCmd := s.Executor.With.Default
+		for _, optionalCmd := range s.Executor.With.Cases {
+			cmd := s.Executor.With.Default
+			err := copier.Copy(&cmd, &optionalCmd)
+			if err != nil {
+				slog.Error("generate stages", "error", err)
+				return stages, err
+			}
+			// since these 3 values are pointers, copier will always copy
+			// them, so we need to check them manually
+			if defaultCmd.Stdin != nil && optionalCmd.Stdin == nil {
+				cmd.Stdin = defaultCmd.Stdin
+			}
+			if defaultCmd.Stdout != nil && optionalCmd.Stdout == nil {
+				cmd.Stdout = defaultCmd.Stdout
+			}
+			if defaultCmd.Stderr != nil && optionalCmd.Stderr == nil {
+				cmd.Stderr = defaultCmd.Stderr
+			}
+			cmds = append(cmds, cmd)
+		}
+		if len(s.Executor.With.Cases) == 0 {
+			cmds = []stage.Cmd{defaultCmd}
+		}
+		stages = append(stages, stage.Stage{
+			Name:         s.Name,
+			ExecutorName: s.Executor.Name,
+			ExecutorCmds: cmds,
+			ParserName:   s.Parser.Name,
+			ParserConf:   s.Parser.With,
+		})
+	}
+	slog.Debug("stages generated", "stages", stages)
+	return stages, nil
+}
+
+func outputResult(outputPath string, results []stage.StageResult) error {
+	slog.Info("output result start", "path", outputPath)
+	slog.Debug("output result start", "path", outputPath, "results", results)
+	content, err := json.Marshal(results)
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(outputPath,
+		append(content, []byte("\n")...), 0o600)
+}
+
+var (
+	confRoot    string
+	confName    string
+	msg         string
+	showVersion *bool
+	Version     string = "debug"
+)
+
+func init() {
+	flag.StringVar(&confRoot, "conf-root", ".", "root path for all config files")
+	flag.StringVar(&confName, "conf-name", "conf.json", "filename for config files")
+	flag.StringVar(&msg, "msg", "", "message to trigger the running, leave empty to use git commit message on HEAD")
+	showVersion = flag.Bool("version", false, "print current version")
+}
+
+func Run() error {
+	if err := setupSlog(""); err != nil { // before conf is loaded
+		return err
+	}
+	flag.Parse()
+	if *showVersion {
+		fmt.Println(Version)
+		return nil
+	}
+	slog.Info("start joj3", "version", Version)
+	if msg == "" {
+		var err error
+		msg, err = getCommitMsg()
+		if err != nil {
+			slog.Error("get commit msg", "error", err)
+			return err
+		}
+	}
+	conf, group, err := parseMsg(confRoot, confName, msg)
+	if err != nil {
+		slog.Error("parse msg", "error", err)
+		validScopes, scopeErr := listValidScopes(
+			confRoot, confName, msg)
+		if scopeErr != nil {
+			slog.Error("list valid scopes", "error", scopeErr)
+			return scopeErr
+		}
+		slog.Info("hint: valid scopes in commit message", "scopes", validScopes)
+		return err
+	}
+	if err := setupSlog(conf.LogPath); err != nil { // after conf is loaded
+		return err
+	}
+	executors.InitWithConf(conf.SandboxExecServer, conf.SandboxToken)
+	stages, err := generateStages(conf, group)
+	if err != nil {
+		slog.Error("generate stages", "error", err)
+		return err
+	}
+	defer stage.Cleanup()
+	results, err := stage.Run(stages)
+	if err != nil {
+		slog.Error("run stages", "error", err)
+		return err
+	}
+	if err := outputResult(conf.OutputPath, results); err != nil {
+		slog.Error("output result", "error", err)
+		return err
+	}
+	return nil
+}
diff --git a/cmd/joj3/main_test.go b/cmd/joj3/stage-runner/main_test.go
similarity index 94%
rename from cmd/joj3/main_test.go
rename to cmd/joj3/stage-runner/main_test.go
index e160176..24ea6b6 100644
--- a/cmd/joj3/main_test.go
+++ b/cmd/joj3/stage-runner/main_test.go
@@ -1,4 +1,4 @@
-package main
+package stagerunner
 
 import (
 	"encoding/json"
@@ -61,9 +61,9 @@ func readStageResults(t *testing.T, path string) []stage.StageResult {
 	return results
 }
 
-func TestMain(t *testing.T) {
+func TestRun(t *testing.T) {
 	var tests []string
-	root := "../../tmp/submodules/JOJ3-examples/examples/"
+	root := "../../../tmp/submodules/JOJ3-examples/examples/"
 	err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
 		if err != nil {
 			return err
@@ -105,7 +105,9 @@ func TestMain(t *testing.T) {
 			os.Args = []string{"./joj3"}
 			outputFile := "joj3_result.json"
 			defer os.Remove(outputFile)
-			main()
+			if err := Run(); err != nil {
+				t.Fatal(err)
+			}
 			stageResults := readStageResults(t, outputFile)
 			regex := true
 			expectedFile := "expected_regex.json"
diff --git a/cmd/joj3/teapot-caller/main.go b/cmd/joj3/teapot-caller/main.go
new file mode 100644
index 0000000..10087f5
--- /dev/null
+++ b/cmd/joj3/teapot-caller/main.go
@@ -0,0 +1,18 @@
+package teapotcaller
+
+import (
+	"log/slog"
+	"os/exec"
+)
+
+func Run() error {
+	// TODO: call teapot
+	cmd := exec.Command("joint-teapot", "--help")
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		slog.Error("running git command:", "err", err)
+		return err
+	}
+	slog.Info("joint-teapot run", "output", string(output))
+	return nil
+}
diff --git a/cmd/healthcheck/main.go b/cmd/repo-health-checker/main.go
similarity index 100%
rename from cmd/healthcheck/main.go
rename to cmd/repo-health-checker/main.go