dodo: Support Sketch agent

Change-Id: I4dcd6aab7d7a2c2e86aaf1ad8d36d30a649ab31d
diff --git a/apps/app-runner/Dockerfile.sketch.latest b/apps/app-runner/Dockerfile.sketch.latest
new file mode 100644
index 0000000..82aba7a
--- /dev/null
+++ b/apps/app-runner/Dockerfile.sketch.latest
@@ -0,0 +1,9 @@
+FROM alpine:3.22.0
+
+ARG TARGETARCH
+
+RUN apk update
+RUN apk add bash curl git nodejs npm
+
+COPY --from=giolekva/sketch:latest /usr/bin/sketch /usr/bin/sketch
+COPY app-runner_${TARGETARCH} /usr/bin/app-runner
diff --git a/apps/app-runner/Makefile b/apps/app-runner/Makefile
index 187ca18..ef1c483 100644
--- a/apps/app-runner/Makefile
+++ b/apps/app-runner/Makefile
@@ -11,6 +11,7 @@
 manifest_dest_deno_2_2_0=docker://docker.io/$(repo_name)/app-runner:deno-2.2.0
 manifest_dest_nodejs_23_1_0=docker://docker.io/$(repo_name)/app-runner:nodejs-23.1.0
 manifest_dest_nodejs_24_0_2=docker://docker.io/$(repo_name)/app-runner:nodejs-24.0.2
+manifest_dest_sketch_latest=docker://docker.io/$(repo_name)/app-runner:sketch-latest
 endif
 
 clean:
@@ -166,6 +167,24 @@
 	$(podman) manifest push $(repo_name)/app-runner:deno-2.2.0 $(manifest_dest_deno_2_2_0)
 	$(podman) manifest rm $(repo_name)/app-runner:deno-2.2.0
 
+# Sketch
+
+push_sketch_arm64: clean build_arm64
+	$(podman) build --platform linux/arm64 --tag=$(repo_name)/app-runner:sketch-latest-arm64 -f Dockerfile.sketch.latest $(docker_flags) .
+	$(podman) push $(repo_name)/app-runner:sketch-latest-arm64
+
+push_sketch_amd64: clean build_amd64
+	$(podman) build --platform linux/amd64 --tag=$(repo_name)/app-runner:sketch-latest-amd64 -f Dockerfile.sketch.latest $(docker_flags) .
+	$(podman) push $(repo_name)/app-runner:sketch-latest-amd64
+
+push_sketch:
+	make -C ../../../sketch push
+	make push_sketch_arm64
+	make push_sketch_amd64
+	$(podman) manifest create $(repo_name)/app-runner:sketch-latest $(repo_name)/app-runner:sketch-latest-arm64 $(repo_name)/app-runner:sketch-latest-amd64
+	$(podman) manifest push $(repo_name)/app-runner:sketch-latest $(manifest_dest_sketch_latest)
+	$(podman) manifest rm $(repo_name)/app-runner:sketch-latest
+
 
 # all
-push: push_golang_1_22_0 push_golang_1_20_0 push_hugo push_php_8_2_apache push_nextjs_deno_2_0_0 push_nodejs_23_1_0 push_nodejs_24_0_2 push_deno_2_2_0
+push: push_golang_1_22_0 push_golang_1_20_0 push_hugo push_php_8_2_apache push_nextjs_deno_2_0_0 push_nodejs_23_1_0 push_nodejs_24_0_2 push_deno_2_2_0 push_sketch
diff --git a/apps/app-runner/main.go b/apps/app-runner/main.go
index 96a647e..ebd0ef8 100644
--- a/apps/app-runner/main.go
+++ b/apps/app-runner/main.go
@@ -21,6 +21,7 @@
 var port = flag.Int("port", 3000, "Port to listen on")
 var appId = flag.String("app-id", "", "Application ID")
 var service = flag.String("service", "", "Service name")
+var agentMode = flag.Bool("agent-mode", false, "Sketch agent mode")
 var repoAddr = flag.String("repo-addr", "", "Git repository address")
 var branch = flag.String("branch", "", "Name of the branch to process")
 var rootDir = flag.String("root-dir", "/", "Path to the app code")
@@ -104,8 +105,10 @@
 			panic(err)
 		}
 	}
-	if err := os.Mkdir(*appDir, os.ModePerm); err != nil {
-		panic(err)
+	if !*agentMode {
+		if err := os.Mkdir(*appDir, os.ModePerm); err != nil {
+			panic(err)
+		}
 	}
 	r, err := os.Open(*runCfg)
 	if err != nil {
@@ -116,7 +119,7 @@
 	if err := json.NewDecoder(r).Decode(&cmds); err != nil {
 		panic(err)
 	}
-	s := NewServer(*port, *appId, *service, id, *repoAddr, *branch, *rootDir, signer, *appDir, cmds, self, *managerAddr)
+	s := NewServer(*agentMode, *port, *appId, *service, id, *repoAddr, *branch, *rootDir, signer, *appDir, cmds, self, *managerAddr)
 	if err := s.Start(); err != nil {
 		log.Fatal(err)
 	}
diff --git a/apps/app-runner/server.go b/apps/app-runner/server.go
index 1f7fe01..89f621a 100644
--- a/apps/app-runner/server.go
+++ b/apps/app-runner/server.go
@@ -31,6 +31,7 @@
 
 type Server struct {
 	l           sync.Locker
+	agentMode   bool
 	port        int
 	appId       string
 	service     string
@@ -50,9 +51,10 @@
 	status      *Status
 }
 
-func NewServer(port int, appId, service, id, repoAddr, branch, rootDir string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
+func NewServer(agentMode bool, port int, appId, service, id, repoAddr, branch, rootDir string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
 	return &Server{
 		l:           &sync.Mutex{},
+		agentMode:   agentMode,
 		port:        port,
 		ready:       false,
 		appId:       appId,
@@ -114,30 +116,80 @@
 	s.l.Unlock()
 }
 
+type command struct {
+	cmd string
+	env []string
+}
+
 func (s *Server) run() error {
-	newDir, err := os.MkdirTemp(s.appDir, "code-*")
-	if err != nil {
-		return err
-	}
-	commit, err := CloneRepositoryBranch(s.repoAddr, s.branch, s.rootDir, s.signer, newDir)
-	if err != nil {
-		fmt.Fprintf(s.logs, "!!! dodo: Failed to clone repository\n")
-		s.status = &Status{
-			Commit: nil,
+	newDir := s.appDir
+	commands := []command{}
+	if !s.agentMode {
+		var err error
+		newDir, err = os.MkdirTemp(s.appDir, "code-*")
+		if err != nil {
+			return err
 		}
-		return err
 	}
-	fmt.Fprintf(s.logs, "!!! dodo: Successfully cloned repository %s\n", commit.Hash)
-	s.status = &Status{
-		Commit:   commit,
-		Commands: []CommandStatus{},
+	if s.repoAddr != "" {
+		commit, err := CloneRepositoryBranch(s.repoAddr, s.branch, s.rootDir, s.signer, newDir)
+		if err != nil {
+			fmt.Fprintf(s.logs, "!!! dodo: Failed to clone repository\n")
+			s.status = &Status{
+				Commit: nil,
+			}
+			return err
+		}
+		fmt.Fprintf(s.logs, "!!! dodo: Successfully cloned repository %s\n", commit.Hash)
+		s.status = &Status{
+			Commit:   commit,
+			Commands: []CommandStatus{},
+		}
+	} else {
+		s.status = &Status{
+			Commit:   nil,
+			Commands: []CommandStatus{},
+		}
 	}
-	commands := []string{}
+	if s.agentMode {
+		if _, err := os.Stat(filepath.Join(newDir, ".git")); err != nil && os.IsNotExist(err) {
+			commands = append(commands, command{cmd: "git config --global user.name dodo"})
+			s.status.Commands = append(s.status.Commands, CommandStatus{
+				Command: commands[len(commands)-1].cmd,
+				State:   "waiting",
+			})
+			commands = append(commands, command{cmd: "git config --global user.email dodo@dodo.cloud"})
+			s.status.Commands = append(s.status.Commands, CommandStatus{
+				Command: commands[len(commands)-1].cmd,
+				State:   "waiting",
+			})
+			commands = append(commands, command{cmd: "git init ."})
+			s.status.Commands = append(s.status.Commands, CommandStatus{
+				Command: commands[len(commands)-1].cmd,
+				State:   "waiting",
+			})
+			commands = append(commands, command{cmd: "echo \"TODO: Describe project\" > README.md"})
+			s.status.Commands = append(s.status.Commands, CommandStatus{
+				Command: commands[len(commands)-1].cmd,
+				State:   "waiting",
+			})
+			commands = append(commands, command{cmd: "git add README.md"})
+			s.status.Commands = append(s.status.Commands, CommandStatus{
+				Command: commands[len(commands)-1].cmd,
+				State:   "waiting",
+			})
+			commands = append(commands, command{cmd: "git commit -m \"init\""})
+			s.status.Commands = append(s.status.Commands, CommandStatus{
+				Command: commands[len(commands)-1].cmd,
+				State:   "waiting",
+			})
+		}
+	}
 	for _, c := range s.runCommands {
 		args := []string{c.Bin}
 		args = append(args, c.Args...)
 		cmd := strings.Join(args, " ")
-		commands = append(commands, cmd)
+		commands = append(commands, command{cmd, c.Env})
 		s.status.Commands = append(s.status.Commands, CommandStatus{
 			Command: cmd,
 			State:   "waiting",
@@ -151,8 +203,8 @@
 		cmd := &exec.Cmd{
 			Dir:    filepath.Join(newDir, s.rootDir),
 			Path:   "/bin/sh",
-			Args:   []string{"/bin/sh", "-c", c},
-			Env:    append(os.Environ(), s.runCommands[i].Env...),
+			Args:   []string{"/bin/sh", "-c", c.cmd},
+			Env:    append(os.Environ(), c.env...),
 			Stdout: logM,
 			Stderr: logM,
 		}
@@ -160,7 +212,7 @@
 		fmt.Printf("Running: %s\n", c)
 		fmt.Fprintf(s.logs, "!!! dodo: Running: %s\n", c)
 		s.status.Commands[i].State = "running"
-		if i < len(s.runCommands)-1 {
+		if i < len(commands)-1 {
 			if err := cmd.Run(); err != nil {
 				return err
 			}
@@ -170,7 +222,7 @@
 				if err := s.kill(); err != nil {
 					return err
 				}
-				if s.currDir != "" {
+				if s.currDir != "" && !s.agentMode {
 					if err := os.RemoveAll(s.currDir); err != nil {
 						return err
 					}
diff --git a/charts/app-runner/templates/install.yaml b/charts/app-runner/templates/install.yaml
index 52b8495..8bc558b 100644
--- a/charts/app-runner/templates/install.yaml
+++ b/charts/app-runner/templates/install.yaml
@@ -108,6 +108,7 @@
               fieldPath: status.podIP
         command:
         - app-runner
+        - --agent-mode={{ .Values.agentMode }}
         - --port={{ .Values.apiPort }}
         - --app-id={{ .Values.appId }}
         - --service={{ .Values.name }}
diff --git a/charts/app-runner/values.yaml b/charts/app-runner/values.yaml
index db623a9..5fa1e89 100644
--- a/charts/app-runner/values.yaml
+++ b/charts/app-runner/values.yaml
@@ -16,3 +16,4 @@
 extraContainers: []
 apiPort: 3000
 name: "app"
+agentMode: false
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index e906f43..c26fa77 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -30,6 +30,11 @@
 		cluster: clusterMap[strings.ToLower(_cluster)]
 	}
 
+	geminiApiKey?: string
+	for v in agent {
+		"sketch_\(v.name)_session_id": string @role(sketch-session-id)
+	}
+
 	for v in _postgresql {
 		for i, e in v.expose {
 			"port_postgresql_\(v.name)_\(i)": int @role(port)
@@ -135,26 +140,30 @@
 }
 
 // TODO(gio): add value
+// Do not specify both alias and value
 #EnvVar: {
 	name:   string
 	alias?: string
+	value?: string
 }
 
 #AppTmpl: {
-	name: string | *"app"
-	type: string
+	nodeId?:   string
+	name:      string | *"app"
+	type:      string
+	agentMode: bool | *false
 	ingress?: [...#AppIngress]
 	expose: [...#PortDomain] | *[]
 	rootDir: string
 	runConfiguration: [...#Command]
 	volume: [...string] | *[]
-	dev: #Dev
-	vm:  #VMCustomization
+	dev: #Dev | *{enabled: false}
+	vm: #VMCustomization
 	// TODO(gio): check for duplicate values
 	apiPort: #PortValue | *3000
 	ports: [...#Port]
 	env: [...#EnvVar] | *[]
-	source: close({
+	source?: close({
 		repository: string
 		branch:     string | *"master"
 		rootDir:    string | *"/"
@@ -184,6 +193,11 @@
 				}
 			},
 		],
+		[
+			for e in env if e.value != _|_ {
+				"\(strings.ToUpper(e.name))=\(e.value)"
+			},
+		],
 	])
 
 	...
@@ -467,9 +481,73 @@
 
 #NodeJSApp: #NodeJS2310 | #NodeJS2402
 
-#App: #GoApp | #HugoApp | #PHPApp | #NextjsApp | #NodeJSApp | #DenoApp
+#SketchApp: #AppTmpl & {
+	agentMode: true
 
-service: [...#App]
+	name:  string
+	_name: name
+
+	type: "sketch:latest"
+
+	geminiApiKey?: string
+	_geminiApiKey: string
+	if geminiApiKey != _|_ {
+		_geminiApiKey: geminiApiKey
+	}
+	if geminiApiKey == _|_ && input.geminiApiKey != _|_ {
+		_geminiApiKey: input.geminiApiKey
+	}
+
+	ports: [{
+		name:  "agent"
+		value: 2001
+	}, {
+		name:  "p8080"
+		value: 8080
+	}, {
+		name:  "p8081"
+		value: 8081
+	}, {
+		name:  "p8082"
+		value: 8082
+	}, {
+		name:  "p8083"
+		value: 8083
+	}, {
+		name:  "p8084"
+		value: 8084
+	}]
+	env: [{
+		name:  "DODO_PROJECT_ID"
+		value: input.appId
+	}, {
+		name:  "DODO_API_BASE_ADDR"
+		value: input.managerAddr
+	}, {
+		name:  "GEMINI_API_KEY"
+		value: _geminiApiKey
+	}]
+	rootDir: "/dodo/volume/\(_name)-apps"
+
+	lastCmdEnv: [...string]
+
+	_sessionId: input["sketch_\(name)_session_id"]
+
+	runConfiguration: [{
+		bin: "sketch -verbose -unsafe -skaband-addr=\"\" -addr=\"0.0.0.0:2001\" -model=gemini -session-id=\"\(_sessionId)\""
+		env: lastCmdEnv
+	}]
+}
+
+#AgentApp: #SketchApp
+
+#NonAgentApp: #GoApp | #HugoApp | #PHPApp | #NextjsApp | #NodeJSApp | #DenoApp
+
+#App: #NonAgentApp | #AgentApp
+
+agent: [...#AgentApp]
+
+service: [...#NonAgentApp]
 
 _serviceDevEnabled: {
 	images: {}
@@ -617,11 +695,19 @@
 					containerPort: p.value
 					protocol:      p.protocol
 				}]
-				appDir:        svc.rootDir
-				appId:         input.appId
-				repoAddr:      svc.source.repository
-				branch:        svc.source.branch
-				rootDir:       svc.source.rootDir
+				appDir: svc.rootDir
+				appId:  input.appId
+				if svc.source != _|_ {
+					repoAddr: svc.source.repository
+					branch:   svc.source.branch
+					rootDir:  svc.source.rootDir
+				}
+				if svc.source == _|_ {
+					repoAddr: ""
+					branch:   ""
+					rootDir:  ""
+				}
+				agentMode:     svc.agentMode
 				sshPrivateKey: base64.Encode(null, input.key.private)
 				runCfg:        base64.Encode(null, json.Marshal(svc.runConfiguration))
 				managerAddr:   input.managerAddr
@@ -730,8 +816,9 @@
 				}]
 				runCmd: list.Concat([[
 					["sh", "-c", "chown \(username):\(username) /home/\(username)/.cache"],
-					["sh", "-c", "GIT_SSH_COMMAND='ssh -i /home/\(username)/.ssh/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new' git clone --branch \(svc.source.branch) \(svc.source.repository) /home/\(username)/code"],
-					["sh", "-c", "chown -R \(username):\(username) /home/\(username)/code"],
+					if svc.source != _|_ {
+						["sh", "-c", "GIT_SSH_COMMAND='ssh -i /home/\(username)/.ssh/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new' git clone --branch \(svc.source.branch) \(svc.source.repository) /home/\(username)/code"]
+					},
 					["sh", "-c", "chown -R \(username):\(username) /home/\(username)"],
 				], svc.vm.cloudInit.runCmd])
 			}
@@ -820,6 +907,12 @@
 		for v in _volume {
 			"\(v.name)": v
 		}
+		for v in agent {
+			"\(v.name)-apps": {
+				name: "\(v.name)-apps"
+				size: "1Gi"
+			}
+		}
 	}
 	postgresql: {
 		for v in _postgresql {
@@ -842,6 +935,15 @@
 				svc:  v
 			}
 		}
+		for v in agent {
+			"\(v.name)": #Service & {
+				name: v.name
+				svc: v & {
+					dev: enabled: false
+					volume: ["\(v.name)-apps"]
+				}
+			}
+		}
 	}
 }
 
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 0965507..eee4841 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -1298,6 +1298,8 @@
 		return []string{}
 	case KindPassword:
 		return []string{}
+	case KindSketchSessionId:
+		return []string{}
 	default:
 		panic("MUST NOT REACH!")
 	}
diff --git a/core/installer/derived.go b/core/installer/derived.go
index 4030623..eebb8ad 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -3,8 +3,10 @@
 import (
 	"fmt"
 	"html/template"
+	"math/rand/v2"
 	"strings"
 
+	"github.com/richardlehane/crock32"
 	"github.com/sethvargo/go-password/password"
 )
 
@@ -101,6 +103,9 @@
 				}
 				ret[k] = psswd
 			}
+			if def.Kind() == KindSketchSessionId {
+				ret[k] = GenerateSketchSessionId()
+			}
 			if def.Kind() == KindVPNAuthKey {
 				enabled := true
 				if v, ok := def.Meta()["enabledField"]; ok {
@@ -146,6 +151,8 @@
 			ret[k] = v
 		case KindPassword:
 			ret[k] = v
+		case KindSketchSessionId:
+			ret[k] = v
 		case KindArrayString:
 			a, ok := v.([]string)
 			if !ok {
@@ -352,3 +359,12 @@
 func GeneratePassword() (string, error) {
 	return password.Generate(20, 5, 0, false, true)
 }
+
+func GenerateSketchSessionId() string {
+	u1, u2 := rand.Uint64(), rand.Uint64N(1<<16)
+	s := crock32.Encode(u1) + crock32.Encode(uint64(u2))
+	if len(s) < 16 {
+		s += strings.Repeat("0", 16-len(s))
+	}
+	return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
+}
diff --git a/core/installer/dodo_app_test.go b/core/installer/dodo_app_test.go
index 22835b6..f81f2f9 100644
--- a/core/installer/dodo_app_test.go
+++ b/core/installer/dodo_app_test.go
@@ -642,3 +642,78 @@
 	}
 	t.Log(string(access))
 }
+
+const sketch = `
+{
+    "agent": [
+        {
+            "type": "sketch:latest",
+            "name": "dev",
+            "geminiApiKey": "foo",
+        }
+    ],
+}
+`
+
+func TestSketch(t *testing.T) {
+	app, err := NewDodoApp([]byte(sketch))
+	if err != nil {
+		for _, e := range errors.Errors(err) {
+			t.Log(e)
+		}
+		t.Fatal(err)
+	}
+	release := Release{
+		Namespace:     "foo",
+		AppInstanceId: "foo-bar",
+		RepoAddr:      "ssh://192.168.100.210:22/config",
+		AppDir:        "/foo/bar",
+	}
+	keyGen := testKeyGen{}
+	r, err := app.Render(release, env, networks, nil, map[string]any{
+		"managerAddr":  "",
+		"appId":        "",
+		"geminiApiKey": "dev",
+	}, nil, keyGen)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(string(r.Raw))
+}
+
+const sketchGlobalGeminiApiKey = `
+{
+    "agent": [
+        {
+            "type": "sketch:latest",
+            "name": "dev",
+        }
+    ],
+}
+`
+
+func TestSketchGlobalGeminiApiKey(t *testing.T) {
+	app, err := NewDodoApp([]byte(sketchGlobalGeminiApiKey))
+	if err != nil {
+		for _, e := range errors.Errors(err) {
+			t.Log(e)
+		}
+		t.Fatal(err)
+	}
+	release := Release{
+		Namespace:     "foo",
+		AppInstanceId: "foo-bar",
+		RepoAddr:      "ssh://192.168.100.210:22/config",
+		AppDir:        "/foo/bar",
+	}
+	keyGen := testKeyGen{}
+	r, err := app.Render(release, env, networks, nil, map[string]any{
+		"managerAddr":  "",
+		"appId":        "",
+		"geminiApiKey": "dev",
+	}, nil, keyGen)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(string(r.Raw))
+}
diff --git a/core/installer/go.mod b/core/installer/go.mod
index b183a46..eb35fb5 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -19,6 +19,7 @@
 	github.com/libdns/libdns v0.2.2
 	github.com/miekg/dns v1.1.58
 	github.com/ncruces/go-sqlite3 v0.17.0
+	github.com/richardlehane/crock32 v1.0.1
 	github.com/sethvargo/go-password v0.3.1
 	github.com/spf13/cobra v1.8.1
 	golang.org/x/crypto v0.32.0
diff --git a/core/installer/go.sum b/core/installer/go.sum
index 1da1bc6..a6448e0 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -363,6 +363,8 @@
 github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
 github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq7lB31IeJL8iy7jkUmU/PG1Sr8jVGhS749dbUA=
 github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d/go.mod h1:jgxiZysxFPM+iWKwQwPR+y+Jvo54ARd4EisXxKYpB5c=
+github.com/richardlehane/crock32 v1.0.1 h1:GV9EqtAr7RminQ8oGrDt3gYXkzDDPJ5fROaO1Mux14g=
+github.com/richardlehane/crock32 v1.0.1/go.mod h1:xUIlLABtHBgs1bNIBdUQR9F2xtRzS0TujtbR68hmEWU=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
diff --git a/core/installer/samples/canvas.rest b/core/installer/samples/canvas.rest
index 1ff7356..42bc95d 100644
--- a/core/installer/samples/canvas.rest
+++ b/core/installer/samples/canvas.rest
@@ -1,54 +1,24 @@
-PUT http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app/dodo-app-gry
+POST http://appmanager.hgrz-appmanager.svc.cluster.local/api/dodo-app
 Content-Type: application/json
 
 {
   "config": {
-    "service": [
+    "input": {
+	  "sketch_dev_gemini_api_key": "AIzaSyAx_vF0HJyT55A09iXtjPhf2JocNOGaWCo"
+	},
+    "agent": [
       {
-        "dev": {
-          "enabled": false
-        },
+        "name": "dev",
         "ingress": [
           {
-            "auth": {
-              "enabled": true,
-			  "noAuthPathPatterns": ["^/api/webhook/github/push$"]
-            },
-            "network": "public",
+            "network": "private",
             "port": {
-              "name": "web"
+              "value": 2001
             },
-            "subdomain": "canvas"
+            "subdomain": "sketch"
           }
-        ],
-        "name": "canvas",
-        "ports": [
-          {
-            "name": "web",
-            "value": 8080
-          },
-		  {
-		    "name": "api",
-			"value": 8081
-		  }
-        ],
-        "source": {
-          "branch": "canvas",
-          "repository": "https://code.v1.dodo.cloud/pcloud",
-          "rootDir": "apps/canvas/back"
-        },
-        "type": "nodejs:24.0.2",
-		"volume": ["data"],
-		"preBuildCommands": [{
-		  "bin": "cd ../front && npm install && npm run build"
-		}, {
-		  "bin": "npx prisma migrate dev"
-		}]
+        ]
       }
-    ],
-	"volume": [{
-	  "name": "data",
-	  "size": "1Gi"
-	}]
+    ]
   }
 }
diff --git a/core/installer/schema.go b/core/installer/schema.go
index 2b150a6..e575aa5 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -12,20 +12,21 @@
 type Kind int
 
 const (
-	KindBoolean      Kind = 0
-	KindInt               = 7
-	KindString            = 1
-	KindStruct            = 2
-	KindNetwork           = 3
-	KindMultiNetwork      = 10
-	KindAuth              = 5
-	KindSSHKey            = 6
-	KindNumber            = 4
-	KindArrayString       = 8
-	KindPort              = 9
-	KindVPNAuthKey        = 11
-	KindCluster           = 12
-	KindPassword          = 13
+	KindBoolean         Kind = 0
+	KindInt                  = 7
+	KindString               = 1
+	KindStruct               = 2
+	KindNetwork              = 3
+	KindMultiNetwork         = 10
+	KindAuth                 = 5
+	KindSSHKey               = 6
+	KindNumber               = 4
+	KindArrayString          = 8
+	KindPort                 = 9
+	KindVPNAuthKey           = 11
+	KindCluster              = 12
+	KindPassword             = 13
+	KindSketchSessionId      = 14
 )
 
 type Field struct {
@@ -309,6 +310,8 @@
 		if role == "password" {
 			// TODO(gio): implement configurable requirements such as min-length, ...
 			return basicSchema{name, KindPassword, false, nil}, nil
+		} else if role == "sketch-session-id" {
+			return basicSchema{name, KindSketchSessionId, false, nil}, nil
 		} else if role == "vpnauthkey" {
 			meta := map[string]string{}
 			usernameFieldAttr := v.Attribute("usernameField")