Canvas: Implement Agent Sketch node, update dodo-app.jsonschema

- Add Gemini API key to the project
- Update dodo schema to support Gemini API key
- Update dodo schema to support Agent Sketch node

Change-Id: I6a96186f86ad169152ca0021b38130e485ebbf14
diff --git a/apps/canvas/front/src/Integrations.tsx b/apps/canvas/front/src/Integrations.tsx
index 722c43c..cf4b586 100644
--- a/apps/canvas/front/src/Integrations.tsx
+++ b/apps/canvas/front/src/Integrations.tsx
@@ -1,4 +1,4 @@
-import { useProjectId, useGithubService, useStateStore } from "@/lib/state";
+import { useProjectId, useGithubService, useStateStore, useGeminiService } from "@/lib/state";
 import { Form, FormControl, FormField, FormItem, FormMessage } from "./components/ui/form";
 import { Input } from "./components/ui/input";
 import { useForm } from "react-hook-form";
@@ -9,25 +9,36 @@
 import { CircleCheck, CircleX } from "lucide-react";
 import { useState, useCallback } from "react";
 
-const schema = z.object({
+const githubSchema = z.object({
 	githubToken: z.string().min(1, "GitHub token is required"),
 });
 
+const geminiSchema = z.object({
+	geminiApiKey: z.string().min(1, "Gemini API token is required"),
+});
+
 export function Integrations() {
 	const { toast } = useToast();
 	const store = useStateStore();
 	const projectId = useProjectId();
-	const [isEditing, setIsEditing] = useState(false);
+	const [isEditingGithub, setIsEditingGithub] = useState(false);
+	const [isEditingGemini, setIsEditingGemini] = useState(false);
 	const githubService = useGithubService();
+	const geminiService = useGeminiService();
 	const [isSaving, setIsSaving] = useState(false);
 
-	const form = useForm<z.infer<typeof schema>>({
-		resolver: zodResolver(schema),
+	const githubForm = useForm<z.infer<typeof githubSchema>>({
+		resolver: zodResolver(githubSchema),
 		mode: "onChange",
 	});
 
-	const onSubmit = useCallback(
-		async (data: z.infer<typeof schema>) => {
+	const geminiForm = useForm<z.infer<typeof geminiSchema>>({
+		resolver: zodResolver(geminiSchema),
+		mode: "onChange",
+	});
+
+	const onGithubSubmit = useCallback(
+		async (data: z.infer<typeof githubSchema>) => {
 			if (!projectId) return;
 
 			setIsSaving(true);
@@ -46,8 +57,8 @@
 				}
 
 				await store.refreshEnv();
-				setIsEditing(false);
-				form.reset();
+				setIsEditingGithub(false);
+				githubForm.reset();
 				toast({
 					title: "GitHub token saved successfully",
 				});
@@ -61,12 +72,51 @@
 				setIsSaving(false);
 			}
 		},
-		[projectId, store, form, toast, setIsEditing, setIsSaving],
+		[projectId, store, githubForm, toast, setIsEditingGithub, setIsSaving],
 	);
 
-	const handleCancel = () => {
-		setIsEditing(false);
-		form.reset();
+	const onGeminiSubmit = useCallback(
+		async (data: z.infer<typeof geminiSchema>) => {
+			if (!projectId) return;
+			setIsSaving(true);
+			try {
+				const response = await fetch(`/api/project/${projectId}/gemini-token`, {
+					method: "POST",
+					headers: {
+						"Content-Type": "application/json",
+					},
+					body: JSON.stringify({ geminiApiKey: data.geminiApiKey }),
+				});
+				if (!response.ok) {
+					throw new Error("Failed to save Gemini token");
+				}
+				await store.refreshEnv();
+				setIsEditingGemini(false);
+				geminiForm.reset();
+				toast({
+					title: "Gemini token saved successfully",
+				});
+			} catch (error) {
+				toast({
+					variant: "destructive",
+					title: "Failed to save Gemini token",
+					description: error instanceof Error ? error.message : "Unknown error",
+				});
+			} finally {
+				setIsSaving(false);
+			}
+		},
+		[projectId, store, geminiForm, toast, setIsEditingGemini, setIsSaving],
+	);
+
+	const handleCancelGithub = () => {
+		setIsEditingGithub(false);
+		githubForm.reset();
+	};
+
+	const handleCancelGemini = () => {
+		setIsEditingGemini(false);
+		geminiForm.reset();
 	};
 
 	return (
@@ -77,13 +127,13 @@
 					<div>Github</div>
 				</div>
 
-				{!!githubService && !isEditing && (
-					<Button variant="outline" className="w-fit" onClick={() => setIsEditing(true)}>
+				{!!githubService && !isEditingGithub && (
+					<Button variant="outline" className="w-fit" onClick={() => setIsEditingGithub(true)}>
 						Update Access Token
 					</Button>
 				)}
 
-				{(!githubService || isEditing) && (
+				{(!githubService || isEditingGithub) && (
 					<div className="flex flex-row items-center gap-1 text-sm">
 						<div>
 							Follow the link to generate new PAT:{" "}
@@ -111,11 +161,11 @@
 						</div>
 					</div>
 				)}
-				{(!githubService || isEditing) && (
-					<Form {...form}>
-						<form className="space-y-2" onSubmit={form.handleSubmit(onSubmit)}>
+				{(!githubService || isEditingGithub) && (
+					<Form {...githubForm}>
+						<form className="space-y-2" onSubmit={githubForm.handleSubmit(onGithubSubmit)}>
 							<FormField
-								control={form.control}
+								control={githubForm.control}
 								name="githubToken"
 								render={({ field }) => (
 									<FormItem>
@@ -136,7 +186,73 @@
 									{isSaving ? "Saving..." : "Save"}
 								</Button>
 								{!!githubService && (
-									<Button type="button" variant="outline" onClick={handleCancel} disabled={isSaving}>
+									<Button
+										type="button"
+										variant="outline"
+										onClick={handleCancelGithub}
+										disabled={isSaving}
+									>
+										Cancel
+									</Button>
+								)}
+							</div>
+						</form>
+					</Form>
+				)}
+			</div>
+			<div className="flex flex-col gap-1">
+				<div className="flex flex-row items-center gap-1">
+					{geminiService ? <CircleCheck /> : <CircleX />}
+					<div>Gemini</div>
+				</div>
+
+				{!!geminiService && !isEditingGemini && (
+					<Button variant="outline" className="w-fit" onClick={() => setIsEditingGemini(true)}>
+						Update API Key
+					</Button>
+				)}
+
+				{(!geminiService || isEditingGemini) && (
+					<div className="flex flex-row items-center gap-1 text-sm">
+						<div>
+							Follow the link to generate new API Key:{" "}
+							<a href="https://aistudio.google.com/app/apikey" target="_blank">
+								https://aistudio.google.com/app/apikey
+							</a>
+						</div>
+					</div>
+				)}
+				{(!geminiService || isEditingGemini) && (
+					<Form {...geminiForm}>
+						<form className="space-y-2" onSubmit={geminiForm.handleSubmit(onGeminiSubmit)}>
+							<FormField
+								control={geminiForm.control}
+								name="geminiApiKey"
+								render={({ field }) => (
+									<FormItem>
+										<FormControl>
+											<Input
+												type="password"
+												placeholder="Gemini API Token"
+												className="w-1/4"
+												{...field}
+											/>
+										</FormControl>
+										<FormMessage />
+									</FormItem>
+								)}
+							/>
+							<div className="flex flex-row items-center gap-1">
+								<Button type="submit" disabled={isSaving}>
+									{isSaving ? "Saving..." : "Save"}
+								</Button>
+								{!!geminiService && (
+									<Button
+										type="button"
+										variant="outline"
+										onClick={handleCancelGemini}
+										disabled={isSaving}
+									>
 										Cancel
 									</Button>
 								)}
diff --git a/apps/canvas/front/src/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
index 96b8f03..2d78abd 100644
--- a/apps/canvas/front/src/components/canvas.tsx
+++ b/apps/canvas/front/src/components/canvas.tsx
@@ -10,7 +10,7 @@
 	Panel,
 	useStoreApi,
 } from "@xyflow/react";
-import { useStateStore, AppState, AppNode, useZoom } from "@/lib/state";
+import { useStateStore, AppState, useZoom } from "@/lib/state";
 import { useShallow } from "zustand/react/shallow";
 import { useCallback, useEffect, useMemo } from "react";
 import { NodeGatewayHttps } from "@/components/node-gateway-https";
@@ -22,6 +22,7 @@
 import { Actions } from "./actions";
 import { NodeGatewayTCP } from "./node-gateway-tcp";
 import { NodeNetwork } from "./node-network";
+import { AppNode } from "config";
 
 const selector = (state: AppState) => ({
 	nodes: state.nodes,
@@ -69,6 +70,7 @@
 			}
 			const sn = instance.getNode(c.source)! as AppNode;
 			const tn = instance.getNode(c.target)! as AppNode;
+
 			if (sn.type === "github") {
 				return c.targetHandle === "repository";
 			}
diff --git a/apps/canvas/front/src/components/icon.tsx b/apps/canvas/front/src/components/icon.tsx
index 02be282..6bded3a 100644
--- a/apps/canvas/front/src/components/icon.tsx
+++ b/apps/canvas/front/src/components/icon.tsx
@@ -1,23 +1,31 @@
-import { accessSchema, NodeType } from "@/lib/state";
 import { ReactElement } from "react";
 import { SiGithub, SiMongodb, SiPostgresql } from "react-icons/si";
 import { GrServices } from "react-icons/gr";
 import { GoFileDirectoryFill } from "react-icons/go";
 import { TbWorldWww } from "react-icons/tb";
 import { PiNetwork } from "react-icons/pi";
-import { AiOutlineGlobal } from "react-icons/ai";
+import { AiOutlineGlobal } from "react-icons/ai"; // Corrected import source
+import { Bot } from "lucide-react"; // Bot import
 import { Terminal } from "lucide-react";
 import { z } from "zod";
+import { AppNode, accessSchema } from "config";
 
 type Props = {
-	type: NodeType | undefined;
+	node: AppNode | undefined;
 	className?: string;
 };
 
-export function Icon({ type, className }: Props): ReactElement {
-	switch (type) {
+export function Icon({ node, className }: Props): ReactElement {
+	if (!node) {
+		return <></>;
+	}
+	switch (node.type) {
 		case "app":
-			return <GrServices className={className} />;
+			if (node.data.type === "sketch:latest") {
+				return <Bot className={className} />;
+			} else {
+				return <GrServices className={className} />;
+			}
 		case "github":
 			return <SiGithub className={className} />;
 		case "gateway-https":
@@ -33,7 +41,7 @@
 		case "network":
 			return <AiOutlineGlobal className={className} />;
 		default:
-			throw new Error(`MUST NOT REACH! ${type}`);
+			throw new Error(`MUST NOT REACH! ${node.type}`);
 	}
 }
 
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 7eb632c..fa08977 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,7 +1,7 @@
 import { v4 as uuidv4 } from "uuid";
 import { NodeRect } from "./node-rect";
 import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
-import { ServiceNode, ServiceTypes } from "config";
+import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
 import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
 import { z } from "zod";
 import { useForm, EventType, DeepPartial } from "react-hook-form";
@@ -27,7 +27,7 @@
 	const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
 	const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
@@ -79,6 +79,10 @@
 	subdomain: z.string().min(1, "required"),
 });
 
+const agentSchema = z.object({
+	geminiApiKey: z.string().optional(),
+});
+
 export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
 	const { data } = node;
 	return (
@@ -146,22 +150,24 @@
 							</TooltipProvider>
 						)}
 					</TabsTrigger>
-					<TabsTrigger value="dev">
-						{isOverview ? (
-							<div className="flex flex-row gap-1 items-center">
-								<Code /> Dev
-							</div>
-						) : (
-							<TooltipProvider>
-								<Tooltip>
-									<TooltipTrigger className="flex flex-row gap-1 items-center">
-										<Code />
-									</TooltipTrigger>
-									<TooltipContent>Dev</TooltipContent>
-								</Tooltip>
-							</TooltipProvider>
-						)}
-					</TabsTrigger>
+					{node.data.type !== "sketch:latest" && (
+						<TabsTrigger value="dev">
+							{isOverview ? (
+								<div className="flex flex-row gap-1 items-center">
+									<Code /> Dev
+								</div>
+							) : (
+								<TooltipProvider>
+									<Tooltip>
+										<TooltipTrigger className="flex flex-row gap-1 items-center">
+											<Code />
+										</TooltipTrigger>
+										<TooltipContent>Dev</TooltipContent>
+									</Tooltip>
+								</TooltipProvider>
+							)}
+						</TabsTrigger>
+					)}
 				</TabsList>
 				<TabsContent value="runtime">
 					<Runtime node={node} disabled={disabled} />
@@ -172,9 +178,11 @@
 				<TabsContent value="vars">
 					<EnvVars node={node} disabled={disabled} />
 				</TabsContent>
-				<TabsContent value="dev">
-					<Dev node={node} disabled={disabled} />
-				</TabsContent>
+				{node.data.type !== "sketch:latest" && (
+					<TabsContent value="dev">
+						<Dev node={node} disabled={disabled} />
+					</TabsContent>
+				)}
 			</Tabs>
 		</>
 	);
@@ -241,49 +249,97 @@
 		},
 		[id, store],
 	);
+	const agentForm = useForm<z.infer<typeof agentSchema>>({
+		resolver: zodResolver(agentSchema),
+		mode: "onChange",
+		defaultValues: {
+			geminiApiKey: data.agent?.geminiApiKey,
+		},
+	});
+	useEffect(() => {
+		const sub = agentForm.watch((value) => {
+			store.updateNodeData<"app">(id, {
+				agent: {
+					geminiApiKey: value.geminiApiKey,
+				},
+			});
+		});
+		return () => sub.unsubscribe();
+	}, [id, agentForm, store]);
 	return (
 		<>
 			<SourceRepo node={node} disabled={disabled} />
-			<Form {...form}>
-				<form className="space-y-2">
-					<Label>Container Image</Label>
-					<FormField
-						control={form.control}
-						name="type"
-						render={({ field }) => (
-							<FormItem>
-								<Select
-									onValueChange={field.onChange}
-									value={field.value || ""}
-									{...typeProps}
-									disabled={disabled}
-								>
+			{node.data.type !== "sketch:latest" && (
+				<Form {...form}>
+					<form className="space-y-2">
+						<Label>Container Image</Label>
+						<FormField
+							control={form.control}
+							name="type"
+							render={({ field }) => (
+								<FormItem>
+									<Select
+										onValueChange={field.onChange}
+										value={field.value || ""}
+										{...typeProps}
+										disabled={disabled}
+									>
+										<FormControl>
+											<SelectTrigger>
+												<SelectValue />
+											</SelectTrigger>
+										</FormControl>
+										<SelectContent>
+											{ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
+												<SelectItem key={t} value={t}>
+													{t}
+												</SelectItem>
+											))}
+										</SelectContent>
+									</Select>
+									<FormMessage />
+								</FormItem>
+							)}
+						/>
+					</form>
+				</Form>
+			)}
+			{node.data.type === "sketch:latest" && (
+				<Form {...agentForm}>
+					<form className="space-y-2">
+						<Label>Gemini API Key</Label>
+						<FormField
+							control={agentForm.control}
+							name="geminiApiKey"
+							render={({ field }) => (
+								<FormItem>
 									<FormControl>
-										<SelectTrigger>
-											<SelectValue />
-										</SelectTrigger>
+										<Input
+											type="password"
+											placeholder="Override Gemini API key"
+											{...field}
+											value={field.value || ""}
+											disabled={disabled}
+										/>
 									</FormControl>
-									<SelectContent>
-										{ServiceTypes.map((t) => (
-											<SelectItem key={t} value={t}>
-												{t}
-											</SelectItem>
-										))}
-									</SelectContent>
-								</Select>
-								<FormMessage />
-							</FormItem>
-						)}
+									<FormMessage />
+								</FormItem>
+							)}
+						/>
+					</form>
+				</Form>
+			)}
+			{node.data.type !== "sketch:latest" && (
+				<>
+					<Label>Pre-Build Commands</Label>
+					<Textarea
+						placeholder="new line separated list of commands to run before running the service"
+						value={data.preBuildCommands}
+						onChange={setPreBuildCommands}
+						disabled={disabled}
 					/>
-				</form>
-			</Form>
-			<Label>Pre-Build Commands</Label>
-			<Textarea
-				placeholder="new line separated list of commands to run before running the service"
-				value={data.preBuildCommands}
-				onChange={setPreBuildCommands}
-				disabled={disabled}
-			/>
+				</>
+			)}
 		</>
 	);
 }
diff --git a/apps/canvas/front/src/components/node-gateway-https.tsx b/apps/canvas/front/src/components/node-gateway-https.tsx
index fe17527..ebd1f61 100644
--- a/apps/canvas/front/src/components/node-gateway-https.tsx
+++ b/apps/canvas/front/src/components/node-gateway-https.tsx
@@ -1,13 +1,5 @@
 import { v4 as uuidv4 } from "uuid";
-import {
-	useStateStore,
-	AppNode,
-	GatewayHttpsNode,
-	ServiceNode,
-	nodeLabel,
-	useEnv,
-	nodeIsConnectable,
-} from "@/lib/state";
+import { useStateStore, nodeLabel, useEnv, nodeIsConnectable } from "@/lib/state";
 import { Handle, Position, useNodes } from "@xyflow/react";
 import { NodeRect } from "./node-rect";
 import { useCallback, useEffect, useMemo } from "react";
@@ -22,6 +14,7 @@
 import { XIcon } from "lucide-react";
 import { Switch } from "./ui/switch";
 import { NodeDetailsProps } from "@/lib/types";
+import { AppNode, GatewayHttpsNode, ServiceNode } from "config";
 
 const schema = z.object({
 	network: z.string().min(1, "reqired"),
@@ -50,7 +43,7 @@
 	const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			{nodeLabel(node)}
 			<Handle
 				type={"source"}
diff --git a/apps/canvas/front/src/components/node-gateway-tcp.tsx b/apps/canvas/front/src/components/node-gateway-tcp.tsx
index 919bf83..6f89c1f 100644
--- a/apps/canvas/front/src/components/node-gateway-tcp.tsx
+++ b/apps/canvas/front/src/components/node-gateway-tcp.tsx
@@ -1,5 +1,5 @@
 import { v4 as uuidv4 } from "uuid";
-import { useStateStore, AppNode, nodeLabel, useEnv, GatewayTCPNode, nodeIsConnectable } from "@/lib/state";
+import { useStateStore, nodeLabel, useEnv, nodeIsConnectable } from "@/lib/state";
 import { Edge, Handle, Position, useNodes } from "@xyflow/react";
 import { NodeRect } from "./node-rect";
 import { useCallback, useEffect, useMemo, useState } from "react";
@@ -11,6 +11,7 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 import { Button } from "./ui/button";
 import { NodeDetailsProps } from "@/lib/types";
+import { AppNode, GatewayTCPNode } from "config";
 
 const schema = z.object({
 	network: z.string().min(1, "reqired"),
@@ -27,7 +28,7 @@
 	const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "tcp"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			{nodeLabel(node)}
 			<Handle
 				type={"source"}
diff --git a/apps/canvas/front/src/components/node-github.tsx b/apps/canvas/front/src/components/node-github.tsx
index ef289cb..fbb63e4 100644
--- a/apps/canvas/front/src/components/node-github.tsx
+++ b/apps/canvas/front/src/components/node-github.tsx
@@ -29,7 +29,7 @@
 	const { id, selected } = node;
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-mongodb.tsx b/apps/canvas/front/src/components/node-mongodb.tsx
index 8b9e53b..865631f 100644
--- a/apps/canvas/front/src/components/node-mongodb.tsx
+++ b/apps/canvas/front/src/components/node-mongodb.tsx
@@ -1,13 +1,14 @@
 import { NodeRect } from "./node-rect";
-import { nodeLabel, MongoDBNode } from "@/lib/state";
+import { nodeLabel } from "@/lib/state";
 import { Handle, Position } from "@xyflow/react";
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
+import { MongoDBNode } from "config";
 
 export function NodeMongoDB(node: MongoDBNode) {
 	const { id, selected } = node;
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-name.tsx b/apps/canvas/front/src/components/node-name.tsx
index 4a62206..fee274a 100644
--- a/apps/canvas/front/src/components/node-name.tsx
+++ b/apps/canvas/front/src/components/node-name.tsx
@@ -1,8 +1,8 @@
 import { useState, useEffect } from "react";
 import { nodeLabel, useStateStore } from "@/lib/state";
-import { AppNode } from "@/lib/state";
 import { Icon } from "./icon";
 import { Input } from "./ui/input";
+import { AppNode } from "config";
 
 export function Name({
 	node,
@@ -24,7 +24,7 @@
 	if (node.type === "github" || node.type === "gateway-https" || node.type === "gateway-tcp") {
 		return (
 			<div className="w-full flex flex-row gap-1 items-center">
-				<Icon type={node.type} />
+				<Icon node={node} />
 				<h3 className="w-full text-lg font-bold cursor-text select-none hover:outline-solid hover:outline-2 hover:outline-gray-200">
 					{nodeLabel(node)}
 				</h3>
@@ -33,7 +33,7 @@
 	}
 	return (
 		<div className="w-full flex flex-row gap-1 items-center">
-			<Icon type={node.type} />
+			<Icon node={node} />
 			{isEditing || editing ? (
 				<Input
 					placeholder="Name"
diff --git a/apps/canvas/front/src/components/node-network.tsx b/apps/canvas/front/src/components/node-network.tsx
index 8fa62f2..55d0b7a 100644
--- a/apps/canvas/front/src/components/node-network.tsx
+++ b/apps/canvas/front/src/components/node-network.tsx
@@ -1,11 +1,12 @@
 import { NodeRect } from "./node-rect";
-import { nodeLabel, NetworkNode } from "@/lib/state";
+import { nodeLabel } from "@/lib/state";
 import { Handle, Position } from "@xyflow/react";
+import { NetworkNode } from "config";
 
 export function NodeNetwork(node: NetworkNode) {
 	const { id, selected } = node;
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-postgresql.tsx b/apps/canvas/front/src/components/node-postgresql.tsx
index 0ae86a1..e33295a 100644
--- a/apps/canvas/front/src/components/node-postgresql.tsx
+++ b/apps/canvas/front/src/components/node-postgresql.tsx
@@ -1,13 +1,14 @@
 import { NodeRect } from "./node-rect";
-import { nodeLabel, PostgreSQLNode } from "@/lib/state";
+import { nodeLabel } from "@/lib/state";
 import { Handle, Position } from "@xyflow/react";
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
+import { PostgreSQLNode } from "config";
 
 export function NodePostgreSQL(node: PostgreSQLNode) {
 	const { id, selected } = node;
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-rect.tsx b/apps/canvas/front/src/components/node-rect.tsx
index a0a1842..da3cb35 100644
--- a/apps/canvas/front/src/components/node-rect.tsx
+++ b/apps/canvas/front/src/components/node-rect.tsx
@@ -1,12 +1,13 @@
-import { NodeType, useMode, useNodeMessages } from "@/lib/state";
+import { useMode, useNodeMessages } from "@/lib/state";
 import { Icon } from "./icon";
 import { useEffect, useState } from "react";
+import { AppNode } from "config";
 
 export type Props = {
 	id: string;
 	selected?: boolean;
 	children: React.ReactNode;
-	type: NodeType;
+	node: AppNode;
 	state?: string | null;
 };
 
@@ -48,7 +49,7 @@
 	return (
 		<div className={classes.join(" ")}>
 			<div style={{ position: "absolute", top: "5px", left: "5px" }}>
-				<Icon type={p.type} />
+				<Icon node={p.node} />
 			</div>
 			{mode === "deploy" && (
 				<div
diff --git a/apps/canvas/front/src/components/node-volume.tsx b/apps/canvas/front/src/components/node-volume.tsx
index 39bf15a..c58b600 100644
--- a/apps/canvas/front/src/components/node-volume.tsx
+++ b/apps/canvas/front/src/components/node-volume.tsx
@@ -1,5 +1,5 @@
 import { NodeRect } from "./node-rect";
-import { nodeIsConnectable, nodeLabel, useStateStore, VolumeNode } from "@/lib/state";
+import { nodeIsConnectable, nodeLabel, useStateStore } from "@/lib/state";
 import { useEffect, useMemo } from "react";
 import { z } from "zod";
 import { DeepPartial, EventType, useForm } from "react-hook-form";
@@ -10,12 +10,13 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
+import { VolumeNode } from "config";
 
 export function NodeVolume(node: VolumeNode) {
 	const { id, data, selected } = node;
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "volume"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				<div>{nodeLabel(node)}</div>
 				<div>{data.type && `${data.type}`}</div>
diff --git a/apps/canvas/front/src/components/resources.tsx b/apps/canvas/front/src/components/resources.tsx
index 1fd631a..3a5b334 100644
--- a/apps/canvas/front/src/components/resources.tsx
+++ b/apps/canvas/front/src/components/resources.tsx
@@ -3,9 +3,10 @@
 import { useCallback, useState } from "react";
 import { Accordion, AccordionTrigger } from "./ui/accordion";
 import { AccordionContent, AccordionItem } from "@radix-ui/react-accordion";
-import { AppState, NodeType, useCategories, useMode, useProjectId, useStateStore } from "@/lib/state";
+import { AppState, useCategories, useMode, useProjectId, useStateStore } from "@/lib/state";
 import { CategoryItem } from "@/lib/categories";
 import { Icon } from "./icon";
+import { AppNode, NodeType } from "config";
 
 function addResource(i: CategoryItem<NodeType>, store: AppState) {
 	const deselected = store.nodes.map((n) => ({
@@ -51,7 +52,7 @@
 										style={{ justifyContent: "flex-start" }}
 										disabled={projectId == null || mode !== "edit"}
 									>
-										<Icon type={item.type} />
+										<Icon node={{ type: item.type, data: item.init } as AppNode} />
 										{item.title}
 									</Button>
 								))}
diff --git a/apps/canvas/front/src/lib/categories.ts b/apps/canvas/front/src/lib/categories.ts
index 8753455..6c655e1 100644
--- a/apps/canvas/front/src/lib/categories.ts
+++ b/apps/canvas/front/src/lib/categories.ts
@@ -1,7 +1,8 @@
-import { NodeType, InitData } from "@/lib/state";
+import { NodeType, InitData } from "config";
 
 export interface CategoryItem<T extends NodeType> {
 	title: string;
+	// TODO(gio): make InitData generic
 	init: InitData;
 	type: T;
 }
@@ -102,4 +103,82 @@
 			},
 		],
 	},
+	{
+		title: "AI", // New Category for AI tools
+		items: [
+			{
+				title: "Agent Sketch",
+				init: {
+					...defaultInit,
+					label: "Agent Sketch", // Default label for the node
+					type: "sketch:latest",
+					ports: [
+						{
+							id: "agent",
+							name: "agent",
+							value: 2001,
+						},
+						{
+							id: "p8080",
+							name: "p8080",
+							value: 8080,
+						},
+						{
+							id: "p8081",
+							name: "p8081",
+							value: 8081,
+						},
+						{
+							id: "p8082",
+							name: "p8082",
+							value: 8082,
+						},
+						{
+							id: "p8083",
+							name: "p8083",
+							value: 8083,
+						},
+						{
+							id: "p8084",
+							name: "p8084",
+							value: 8084,
+						},
+					],
+					envVars: [
+						{
+							id: "agent",
+							name: "DODO_PORT_AGENT",
+							source: null,
+						},
+						{
+							id: "p8080",
+							name: "DODO_PORT_P8080",
+							source: null,
+						},
+						{
+							id: "p8081",
+							name: "DODO_PORT_P8081",
+							source: null,
+						},
+						{
+							id: "p8082",
+							name: "DODO_PORT_P8082",
+							source: null,
+						},
+						{
+							id: "p8083",
+							name: "DODO_PORT_P8083",
+							source: null,
+						},
+						{
+							id: "p8084",
+							name: "DODO_PORT_P8084",
+							source: null,
+						},
+					],
+				},
+				type: "app",
+			},
+		],
+	},
 ];
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 39db5b4..f6dee32 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -34,7 +34,11 @@
 			return 4;
 		case "gateway-https":
 			return 5;
-		case undefined:
+		case "gateway-tcp":
+			return 7;
+		case "network":
+			return 8;
+		case undefined: // For NANode
 			return 100;
 	}
 }
@@ -153,6 +157,7 @@
 			}),
 		);
 	const noSource = apps
+		.filter((n) => n.data.type !== "sketch:latest")
 		.filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
 		.map(
 			(n): Message => ({
@@ -194,44 +199,46 @@
 				onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
 			}),
 		);
-	const noIngress = apps.flatMap((n): Message[] => {
-		if (n.data == null) {
-			return [];
-		}
-		return (n.data.ports || [])
-			.filter(
-				(p) =>
-					!nodes
-						.filter((i) => i.type === "gateway-https")
-						.some((i) => {
-							if (
-								i.data &&
-								i.data.https &&
-								i.data.https.serviceId === n.id &&
-								i.data.https.portId === p.id
-							) {
-								return true;
-							}
-							return false;
-						}),
-			)
-			.map(
-				(p): Message => ({
-					id: `${n.id}-${p.id}-no-ingress`,
-					type: "WARNING",
-					nodeId: n.id,
-					message: `Connect to gateway: ${p.name} - ${p.value}`,
-					onHighlight: (store) => {
-						store.updateNode(n.id, { selected: true });
-						store.setHighlightCategory("gateways", true);
-					},
-					onLooseHighlight: (store) => {
-						store.updateNode(n.id, { selected: false });
-						store.setHighlightCategory("gateways", false);
-					},
-				}),
-			);
-	});
+	const noIngress = apps
+		.filter((n) => n.data.type !== "sketch:latest")
+		.flatMap((n): Message[] => {
+			if (n.data == null) {
+				return [];
+			}
+			return (n.data.ports || [])
+				.filter(
+					(p) =>
+						!nodes
+							.filter((i) => i.type === "gateway-https")
+							.some((i) => {
+								if (
+									i.data &&
+									i.data.https &&
+									i.data.https.serviceId === n.id &&
+									i.data.https.portId === p.id
+								) {
+									return true;
+								}
+								return false;
+							}),
+				)
+				.map(
+					(p): Message => ({
+						id: `${n.id}-${p.id}-no-ingress`,
+						type: "WARNING",
+						nodeId: n.id,
+						message: `Connect to gateway: ${p.name} - ${p.value}`,
+						onHighlight: (store) => {
+							store.updateNode(n.id, { selected: true });
+							store.setHighlightCategory("gateways", true);
+						},
+						onLooseHighlight: (store) => {
+							store.updateNode(n.id, { selected: false });
+							store.setHighlightCategory("gateways", false);
+						},
+					}),
+				);
+		});
 	const multipleIngress = apps
 		.filter((n) => n.data != null && n.data.ports != null)
 		.flatMap((n) =>
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 8ada938..3733e56 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -46,7 +46,7 @@
 			case "volume":
 				return n.data.label || "Volume";
 			case undefined:
-				throw new Error("MUST NOT REACH!");
+				throw new Error(`nodeLabel: Node type is undefined. Node ID: ${n.id}, Data: ${JSON.stringify(n.data)}`);
 		}
 	} catch (e) {
 		console.error("opaa", e);
@@ -106,7 +106,9 @@
 			}
 			return true;
 		case undefined:
-			throw new Error("MUST NOT REACH!");
+			throw new Error(
+				`nodeIsConnectable: Node type is undefined. Node ID: ${n.id}, Handle: ${handle}, Data: ${JSON.stringify(n.data)}`,
+			);
 	}
 }
 
@@ -153,12 +155,12 @@
 };
 
 const defaultEnv: Env = {
-	managerAddr: undefined,
 	deployKeyPublic: undefined,
 	instanceId: undefined,
 	networks: [],
 	integrations: {
 		github: false,
+		gemini: false,
 	},
 	services: [],
 	user: {
@@ -225,7 +227,6 @@
 const projectIdSelector = (state: AppState) => state.projectId;
 const categoriesSelector = (state: AppState) => state.categories;
 const messagesSelector = (state: AppState) => state.messages;
-const githubServiceSelector = (state: AppState) => state.githubService;
 const envSelector = (state: AppState) => state.env;
 const zoomSelector = (state: AppState) => state.zoom;
 const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
@@ -268,8 +269,12 @@
 	return useStateStore(envSelector);
 }
 
-export function useGithubService(): GitHubService | null {
-	return useStateStore(githubServiceSelector);
+export function useGithubService(): boolean {
+	return useStateStore(envSelector).integrations.github;
+}
+
+export function useGeminiService(): boolean {
+	return useStateStore(envSelector).integrations.gemini;
 }
 
 export function useGithubRepositories(): GitHubRepository[] {
@@ -457,7 +462,9 @@
 			if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
 				const sourceEnvVars = nodeEnvVarNames(sn);
 				if (sourceEnvVars.length === 0) {
-					throw new Error("MUST NOT REACH!");
+					throw new Error(
+						`onConnect (env_var): Source node ${sn.id} (type: ${sn.type}) has no env vars to connect from.`,
+					);
 				}
 				const id = uuidv4();
 				if (sourceEnvVars.length === 1) {
diff --git a/apps/canvas/front/src/lib/types.ts b/apps/canvas/front/src/lib/types.ts
index 983b13e..09d1ba0 100644
--- a/apps/canvas/front/src/lib/types.ts
+++ b/apps/canvas/front/src/lib/types.ts
@@ -1,4 +1,4 @@
-import { AppNode } from "./state";
+import { AppNode } from "config";
 
 export interface NodeDetailsProps<T extends AppNode = AppNode> {
 	node: T;