diff --git a/MANIFEST.in b/MANIFEST.in
index bf2bea26..94b39eaa 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
 recursive-include agentstack/templates *
 recursive-include agentstack/frameworks/templates *
 recursive-include agentstack/_tools *
+recursive-include agentstack/serve/serve.py
 include agentstack.json .env .env.example
\ No newline at end of file
diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py
index a40e83ff..84ed91e6 100644
--- a/agentstack/cli/cli.py
+++ b/agentstack/cli/cli.py
@@ -1,12 +1,28 @@
 from typing import Optional
 import os, sys
+import importlib
+from typing import Optional
+import os
+import time
+from datetime import datetime
+
+import json
+import shutil
 from art import text2art
 import inquirer
 from agentstack import conf, log
+from agentstack.cli.agentstack_data import CookiecutterData, ProjectStructure, ProjectMetadata, FrameworkData
 from agentstack.conf import ConfigFile
+from agentstack.generation import InsertionPoint, ProjectFile
+from agentstack import frameworks
+from agentstack import inputs
+from agentstack.agents import get_all_agents
+from agentstack.tasks import get_all_tasks
+from agentstack.utils import get_package_path, open_json_file, term_color, is_snake_case, get_framework, \
+    validator_not_empty, verify_agentstack_project
+from agentstack.templates import TemplateConfig
 from agentstack.exceptions import ValidationError
 from agentstack.utils import validator_not_empty, is_snake_case
-from agentstack.generation import InsertionPoint
 
 
 PREFERRED_MODELS = [
@@ -81,6 +97,305 @@ def get_validated_input(
         return value
 
 
+def ask_agent_details():
+    agent = {}
+
+    agent['name'] = get_validated_input(
+        "What's the name of this agent? (snake_case)", min_length=3, snake_case=True
+    )
+
+    agent['role'] = get_validated_input("What role does this agent have?", min_length=3)
+
+    agent['goal'] = get_validated_input("What is the goal of the agent?", min_length=10)
+
+    agent['backstory'] = get_validated_input("Give your agent a backstory", min_length=10)
+
+    agent['model'] = inquirer.list_input(
+        message="What LLM should this agent use?", choices=PREFERRED_MODELS, default=PREFERRED_MODELS[0]
+    )
+
+    return agent
+
+
+def ask_task_details(agents: list[dict]) -> dict:
+    task = {}
+
+    task['name'] = get_validated_input(
+        "What's the name of this task? (snake_case)", min_length=3, snake_case=True
+    )
+
+    task['description'] = get_validated_input("Describe the task in more detail", min_length=10)
+
+    task['expected_output'] = get_validated_input(
+        "What do you expect the result to look like? (ex: A 5 bullet point summary of the email)",
+        min_length=10,
+    )
+
+    task['agent'] = inquirer.list_input(
+        message="Which agent should be assigned this task?",
+        choices=[a['name'] for a in agents],
+    )
+
+    return task
+
+
+def ask_design() -> dict:
+    use_wizard = inquirer.confirm(
+        message="Would you like to use the CLI wizard to set up agents and tasks?",
+    )
+
+    if not use_wizard:
+        return {'agents': [], 'tasks': []}
+
+    os.system("cls" if os.name == "nt" else "clear")
+
+    title = text2art("AgentWizard", font="shimrod")
+
+    print(title)
+
+    print("""
+šŸŖ„ welcome to the agent builder wizard!! šŸŖ„
+
+First we need to create the agents that will work together to accomplish tasks:
+    """)
+    make_agent = True
+    agents = []
+    while make_agent:
+        print('---')
+        print(f"Agent #{len(agents)+1}")
+        agent = None
+        agent = ask_agent_details()
+        agents.append(agent)
+        make_agent = inquirer.confirm(message="Create another agent?")
+
+    print('')
+    for x in range(3):
+        time.sleep(0.3)
+        print('.')
+    print('Boom! We made some agents (ノ>ω<)ノ :td:*:ļ½„ļ¾Ÿā€™ā˜…,td:*:ļ½„ļ¾Ÿā€™ā˜†')
+    time.sleep(0.5)
+    print('')
+    print('Now lets make some tasks for the agents to accomplish!')
+    print('')
+
+    make_task = True
+    tasks = []
+    while make_task:
+        print('---')
+        print(f"Task #{len(tasks) + 1}")
+        task = ask_task_details(agents)
+        tasks.append(task)
+        make_task = inquirer.confirm(message="Create another task?")
+
+    print('')
+    for x in range(3):
+        time.sleep(0.3)
+        print('.')
+    print('Let there be tasks (惎 ˘_˘)ćƒŽć€€Ī¶|||ζ ζ|||ζ ζ|||ζ')
+
+    return {'tasks': tasks, 'agents': agents}
+
+
+def ask_tools() -> list:
+    use_tools = inquirer.confirm(
+        message="Do you want to add agent tools now? (you can do this later with `agentstack tools add <tool_name>`)",
+    )
+
+    if not use_tools:
+        return []
+
+    tools_to_add = []
+
+    adding_tools = True
+    script_dir = os.path.dirname(os.path.abspath(__file__))
+    tools_json_path = os.path.join(script_dir, '..', 'tools', 'tools.json')
+
+    # Load the JSON data
+    tools_data = open_json_file(tools_json_path)
+
+    while adding_tools:
+        tool_type = inquirer.list_input(
+            message="What category tool do you want to add?",
+            choices=list(tools_data.keys()) + ["~~ Stop adding tools ~~"],
+        )
+
+        tools_in_cat = [f"{t['name']} - {t['url']}" for t in tools_data[tool_type] if t not in tools_to_add]
+        tool_selection = inquirer.list_input(message="Select your tool", choices=tools_in_cat)
+
+        tools_to_add.append(tool_selection.split(' - ')[0])
+
+        log.info("Adding tools:")
+        for t in tools_to_add:
+            log.info(f'  - {t}')
+        log.info('')
+        adding_tools = inquirer.confirm("Add another tool?")
+
+    return tools_to_add
+
+
+def ask_project_details(slug_name: Optional[str] = None) -> dict:
+    name = inquirer.text(message="What's the name of your project (snake_case)", default=slug_name or '')
+
+    if not is_snake_case(name):
+        log.error("Project name must be snake case")
+        return ask_project_details(slug_name)
+
+    questions = inquirer.prompt(
+        [
+            inquirer.Text("version", message="What's the initial version", default="0.1.0"),
+            inquirer.Text("description", message="Enter a description for your project"),
+            inquirer.Text("author", message="Who's the author (your name)?"),
+        ]
+    )
+
+    questions['name'] = name
+
+    return questions
+
+
+def insert_template(
+    project_details: dict,
+    framework_name: str,
+    design: dict,
+    template_data: Optional[TemplateConfig] = None,
+):
+    framework = FrameworkData(
+        name=framework_name.lower(),
+    )
+    project_metadata = ProjectMetadata(
+        project_name=project_details["name"],
+        description=project_details["description"],
+        author_name=project_details["author"],
+        version="0.0.1",
+        license="MIT",
+        year=datetime.now().year,
+        template=template_data.name if template_data else 'none',
+        template_version=template_data.template_version if template_data else 0,
+    )
+
+    project_structure = ProjectStructure(
+        method=template_data.method if template_data else "sequential",
+        manager_agent=template_data.manager_agent if template_data else None,
+    )
+    project_structure.agents = design["agents"]
+    project_structure.tasks = design["tasks"]
+    project_structure.inputs = design["inputs"]
+
+    cookiecutter_data = CookiecutterData(
+        project_metadata=project_metadata,
+        structure=project_structure,
+        framework=framework_name.lower(),
+    )
+
+    template_path = get_package_path() / f'templates/{framework.name}'
+    with open(f"{template_path}/cookiecutter.json", "w") as json_file:
+        json.dump(cookiecutter_data.to_dict(), json_file)
+        # TODO this should not be written to the package directory
+
+    # copy .env.example to .env
+    shutil.copy(
+        f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env.example',
+        f'{template_path}/{"{{cookiecutter.project_metadata.project_slug}}"}/.env',
+    )
+
+    cookiecutter(str(template_path), no_input=True, extra_context=None)
+
+    # TODO: inits a git repo in the directory the command was run in
+    # TODO: not where the project is generated. Fix this
+    # TODO: also check if git is installed or if there are any git repos above the current dir
+    try:
+        pass
+        # subprocess.check_output(["git", "init"])
+        # subprocess.check_output(["git", "add", "."])
+    except:
+        print("Failed to initialize git repository. Maybe you're already in one? Do this with: git init")
+
+
+def export_template(output_filename: str):
+    """
+    Export the current project as a template.
+    """
+    try:
+        metadata = ProjectFile()
+    except Exception as e:
+        raise Exception(f"Failed to load project metadata: {e}")
+
+    # Read all the agents from the project's agents.yaml file
+    agents: list[TemplateConfig.Agent] = []
+    for agent in get_all_agents():
+        agents.append(
+            TemplateConfig.Agent(
+                name=agent.name,
+                role=agent.role,
+                goal=agent.goal,
+                backstory=agent.backstory,
+                allow_delegation=False,  # TODO
+                model=agent.llm,  # TODO consistent naming (llm -> model)
+            )
+        )
+
+    # Read all the tasks from the project's tasks.yaml file
+    tasks: list[TemplateConfig.Task] = []
+    for task in get_all_tasks():
+        tasks.append(
+            TemplateConfig.Task(
+                name=task.name,
+                description=task.description,
+                expected_output=task.expected_output,
+                agent=task.agent,
+            )
+        )
+
+    # Export all of the configured tools from the project
+    tools_agents: dict[str, list[str]] = {}
+    for agent_name in frameworks.get_agent_names():
+        for tool_name in frameworks.get_agent_tool_names(agent_name):
+            if not tool_name:
+                continue
+            if tool_name not in tools_agents:
+                tools_agents[tool_name] = []
+            tools_agents[tool_name].append(agent_name)
+
+    tools: list[TemplateConfig.Tool] = []
+    for tool_name, agent_names in tools_agents.items():
+        tools.append(
+            TemplateConfig.Tool(
+                name=tool_name,
+                agents=agent_names,
+            )
+        )
+
+    template = TemplateConfig(
+        template_version=3,
+        name=metadata.project_name,
+        description=metadata.project_description,
+        framework=get_framework(),
+        method="sequential",  # TODO this needs to be stored in the project somewhere
+        manager_agent=None,  # TODO
+        agents=agents,
+        tasks=tasks,
+        tools=tools,
+        inputs=inputs.get_inputs(),
+    )
+
+    try:
+        template.write_to_file(conf.PATH / output_filename)
+        log.success(f"Template saved to: {conf.PATH / output_filename}")
+    except Exception as e:
+        raise Exception(f"Failed to write template to file: {e}")
+
+
+def serve_project():
+    verify_agentstack_project()
+
+    # TODO: only silence output conditionally - maybe a debug or verbose option
+    os.system("docker stop agentstack-local > /dev/null 2>&1")
+    os.system("docker rm agentstack-local > /dev/null 2>&1")
+    with importlib.resources.path('agentstack.serve', 'Dockerfile') as path:
+        os.system(f"docker build -t agent-service -f {path} . --progress=plain")
+    os.system("docker run --name agentstack-local -p 6969:6969 agent-service")
+
+
 def parse_insertion_point(position: Optional[str] = None) -> Optional[InsertionPoint]:
     """
     Parse an insertion point CLI argument into an InsertionPoint enum.
diff --git a/agentstack/cli/run.py b/agentstack/cli/run.py
index 3e142b19..a33b2f19 100644
--- a/agentstack/cli/run.py
+++ b/agentstack/cli/run.py
@@ -1,4 +1,4 @@
-from typing import Optional, List
+from typing import Optional, List, Dict
 import sys
 import asyncio
 import traceback
@@ -16,7 +16,7 @@
 MAIN_MODULE_NAME = "main"
 
 
-def _format_friendly_error_message(exception: Exception):
+def format_friendly_error_message(exception: Exception):
     """
     Projects will throw various errors, especially on first runs, so we catch
     them here and print a more helpful message.
@@ -133,4 +133,4 @@ def run_project(command: str = 'run', cli_args: Optional[List[str]] = None):
     except ImportError as e:
         raise ValidationError(f"Failed to import AgentStack project at: {conf.PATH.absolute()}\n{e}")
     except Exception as e:
-        raise Exception(_format_friendly_error_message(e))
+        raise Exception(format_friendly_error_message(e))
diff --git a/agentstack/cli/spinner.py b/agentstack/cli/spinner.py
new file mode 100644
index 00000000..dc70ba58
--- /dev/null
+++ b/agentstack/cli/spinner.py
@@ -0,0 +1,95 @@
+import itertools
+import shutil
+import sys
+import threading
+import time
+from typing import Optional, Literal
+
+from agentstack import log
+
+
+class Spinner:
+    def __init__(self, message="Working", delay=0.1):
+        self.spinner = itertools.cycle(['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā '])
+        self.delay = delay
+        self.message = message
+        self.running = False
+        self.spinner_thread = None
+        self.start_time = None
+        self._lock = threading.Lock()
+        self._last_printed_len = 0
+        self._last_message = ""
+
+    def _clear_line(self):
+        """Clear the current line in terminal."""
+        sys.stdout.write('\r' + ' ' * self._last_printed_len + '\r')
+        sys.stdout.flush()
+
+    def spin(self):
+        while self.running:
+            with self._lock:
+                elapsed = time.time() - self.start_time
+                terminal_width = shutil.get_terminal_size().columns
+                spinner_char = next(self.spinner)
+                time_str = f"{elapsed:.1f}s"
+
+                # Format: [spinner] Message... [time]
+                message = f"\r{spinner_char} {self.message}... [{time_str}]"
+
+                # Ensure we don't exceed terminal width
+                if len(message) > terminal_width:
+                    message = message[:terminal_width - 3] + "..."
+
+                # Clear previous line and print new one
+                self._clear_line()
+                sys.stdout.write(message)
+                sys.stdout.flush()
+                self._last_printed_len = len(message)
+
+            time.sleep(self.delay)
+
+    def __enter__(self):
+        self.start()
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.stop()
+
+    def start(self):
+        if not self.running:
+            self.running = True
+            self.start_time = time.time()
+            self.spinner_thread = threading.Thread(target=self.spin)
+            self.spinner_thread.start()
+
+    def stop(self):
+        if self.running:
+            self.running = False
+            if self.spinner_thread:
+                self.spinner_thread.join()
+            with self._lock:
+                self._clear_line()
+
+    def update_message(self, message):
+        """Update spinner message and ensure clean line."""
+        with self._lock:
+            self._clear_line()
+            self.message = message
+
+    def clear_and_log(self, message, color: Literal['success', 'info'] = 'success'):
+        """Temporarily clear spinner, print message, and resume spinner.
+        Skips printing if message is the same as the last message printed."""
+        with self._lock:
+            # Skip if message is same as last one
+            if hasattr(self, '_last_message') and self._last_message == message:
+                return
+
+            self._clear_line()
+            if color == 'success':
+                log.success(message)
+            else:
+                log.info(message)
+            sys.stdout.flush()
+
+            # Store current message
+            self._last_message = message
\ No newline at end of file
diff --git a/agentstack/conf.py b/agentstack/conf.py
index 64319f4b..77ba038b 100644
--- a/agentstack/conf.py
+++ b/agentstack/conf.py
@@ -71,6 +71,8 @@ class ConfigFile(BaseModel):
 
     Config Schema
     -------------
+    project_name: str
+        The name of the project.
     framework: str
         The framework used in the project. Defaults to 'crewai'.
     tools: list[str]
@@ -87,8 +89,11 @@ class ConfigFile(BaseModel):
         The version of the template system used to generate the project.
     use_git: Optional[bool]
         Whether to use git for automatic commits of you project.
+    hosted_project_id: Optional[str]
+        The ID of the deployed project on https://AgentStack.sh
     """
 
+    project_name: str
     framework: str = DEFAULT_FRAMEWORK  # TODO this should probably default to None
     tools: list[str] = []
     telemetry_opt_out: Optional[bool] = None
@@ -96,6 +101,7 @@ class ConfigFile(BaseModel):
     agentstack_version: Optional[str] = get_version()
     template: Optional[str] = None
     template_version: Optional[str] = None
+    hosted_project_id: Optional[int] = None
     use_git: Optional[bool] = True
 
     def __init__(self):
diff --git a/agentstack/deploy.py b/agentstack/deploy.py
new file mode 100644
index 00000000..030af0dc
--- /dev/null
+++ b/agentstack/deploy.py
@@ -0,0 +1,217 @@
+import asyncio
+import json
+import os
+import tempfile
+import time
+import tomllib
+import webbrowser
+import zipfile
+from pathlib import Path
+
+from agentstack.auth import get_stored_token, login
+from agentstack.cli.spinner import Spinner
+from agentstack.conf import ConfigFile
+from agentstack.utils import term_color
+from agentstack import log
+import requests
+import websockets
+
+
+# ORIGIN = "localhost:3000"
+ORIGIN = "build.agentstack.sh"
+# PROTOCOL = "http"
+PROTOCOL = "https" # "http"
+
+async def connect_websocket(project_id, spinner):
+    uri = f"ws://{ORIGIN}/ws/build/{project_id}"
+    async with websockets.connect(uri) as websocket:
+        try:
+            while True:
+                message = await websocket.recv()
+                data = json.loads(message)
+                if data['type'] == 'build':
+                    spinner.clear_and_log(f"šŸ—ļø  {data.get('data','')}", 'info')
+                elif data['type'] == 'push':
+                    spinner.clear_and_log(f"šŸ“¤ {data.get('data','')}", 'info')
+                elif data['type'] == 'connected':
+                    spinner.clear_and_log(f"\n\n~~ Build stream connected! ~~")
+                elif data['type'] == 'deploy':
+                    spinner.clear_and_log(f"šŸš€ {data.get('data','')}", 'info')
+                elif data['type'] == 'error':
+                    raise Exception(f"Failed to deploy: {data.get('data')}")
+                elif data['type'] == 'complete':
+                    return
+        except websockets.ConnectionClosed:
+            raise Exception("Websocket connection closed unexpectedly")
+
+
+async def deploy():
+    log.info("Deploying your agentstack agent!")
+    bearer_token = get_stored_token()
+    if not bearer_token:
+        success = login()
+        if success:
+            bearer_token = get_stored_token()
+        else:
+            log.error(term_color("Failed to authenticate with AgentStack.sh", "red"))
+            return
+
+    project_id = get_project_id()
+
+    with Spinner() as spinner:
+        websocket_task = asyncio.create_task(connect_websocket(project_id, spinner))
+        time.sleep(0.1)
+        try:
+            spinner.update_message("Collecting files")
+            spinner.clear_and_log("  šŸ—„ļø Files collected")
+            files = collect_files(str(Path('.')), ('.py', '.toml', '.yaml', '.json'))
+            if not files:
+                raise Exception("No files found to deploy")
+
+            spinner.update_message("Creating zip file")
+            zip_file = create_zip_in_memory(files, spinner)
+            spinner.clear_and_log("  šŸ—œļø Created zip file")
+
+            spinner.update_message("Uploading to server")
+
+            response = requests.post(
+                f'{PROTOCOL}://{ORIGIN}/deploy/build',
+                files={'code': ('code.zip', zip_file)},
+                params={'projectId': project_id},
+                headers={'Authorization': f'Bearer {bearer_token}'}
+            )
+
+            spinner.clear_and_log("  šŸ“”  Uploaded to server")
+
+            if response.status_code != 200:
+                raise Exception(response.text)
+
+            spinner.update_message("Building and deploying your agent")
+
+            # Wait for build completion
+            await websocket_task
+
+            log.success("\nšŸš€ Successfully deployed with AgentStack.sh! Opening in browser...")
+            # webbrowser.open(f"http://localhost:5173/project/{project_id}") # TODO: agentops platform url
+
+        except Exception as e:
+            spinner.stop()
+            log.error(f"\nšŸ™ƒ Failed to deploy with AgentStack.sh: {e}")
+            return
+
+def load_pyproject():
+   if os.path.exists("pyproject.toml"):
+       with open("pyproject.toml", "rb") as f:
+           return tomllib.load(f)
+   return None
+
+def get_project_id():
+    project_config = ConfigFile()
+    project_id = project_config.hosted_project_id
+
+    if project_id:
+        return project_id
+
+    bearer_token = get_stored_token()
+
+    # if not in config, create project and store it
+    log.info("🚧 Creating AgentStack.sh Project")
+    headers = {
+        'Authorization': f'Bearer {bearer_token}',
+        'Content-Type': 'application/json'
+    }
+
+    payload = {
+        'name': project_config.project_name
+    }
+
+    try:
+        response = requests.post(
+            url=f"{PROTOCOL}://{ORIGIN}/projects",
+            # url="https://api.agentstack.sh/projects",
+            headers=headers,
+            json=payload
+        )
+
+        response.raise_for_status()
+        res_data = response.json()
+        project_id = res_data['id']
+        project_config.hosted_project_id = project_id
+        project_config.write()
+        return project_id
+
+    except requests.exceptions.RequestException as e:
+        log.error(f"Error making request: {e}")
+        return None
+
+
+def collect_files(root_path='.', file_types=('.py', '.toml', '.yaml', '.json')):
+    """Collect files of specified types from directory tree."""
+    files = set()  # Using set for faster lookups and unique entries
+    root = Path(root_path)
+
+    def should_process_dir(path):
+        """Check if directory should be processed."""
+        skip_dirs = {'.git', '.venv', 'venv', '__pycache__', 'node_modules', '.pytest_cache'}
+        return path.name not in skip_dirs
+
+    def process_directory(path):
+        """Process a directory and collect matching files."""
+        if not should_process_dir(path):
+            return set()
+
+        matching_files = set()
+        try:
+            for file_path in path.iterdir():
+                if file_path.is_file() and file_path.suffix in file_types:
+                    matching_files.add(file_path)
+                elif file_path.is_dir():
+                    matching_files.update(process_directory(file_path))
+        except PermissionError:
+            log.error(f"Permission denied accessing {path}")
+        except Exception as e:
+            log.error(f"Error processing {path}: {e}")
+
+        return matching_files
+
+    # Start with files in root directory
+    files.update(f for f in root.iterdir() if f.is_file() and f.suffix in file_types)
+
+    # Process subdirectories
+    for path in root.iterdir():
+        if path.is_dir():
+            files.update(process_directory(path))
+
+    return sorted(files)  # Convert back to sorted list for consistent ordering
+
+
+def create_zip_in_memory(files, spinner):
+    """Create a ZIP file in memory with progress updates."""
+    tmp = tempfile.SpooledTemporaryFile(max_size=10 * 1024 * 1024)
+    total_files = len(files)
+
+    with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
+        for i, file in enumerate(files, 1):
+            try:
+                spinner.update_message(f"Adding files to zip ({i}/{total_files})")
+                zf.write(file)
+            except Exception as e:
+                log.error(f"Error adding {file} to zip: {e}")
+
+    tmp.seek(0)
+
+    # Get final zip size
+    current_pos = tmp.tell()
+    tmp.seek(0, 2)  # Seek to end
+    zip_size = tmp.tell()
+    tmp.seek(current_pos)  # Restore position
+
+    def format_size(size_bytes):
+        for unit in ['B', 'KB', 'MB', 'GB']:
+            if size_bytes < 1024 or unit == 'GB':
+                return f"{size_bytes:.1f}{unit}"
+            size_bytes /= 1024
+
+    # log.info(f"    > Zip created: {format_size(zip_size)}")
+
+    return tmp
\ No newline at end of file
diff --git a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json
index 5511a17a..3243f1a6 100644
--- a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json
+++ b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/agentstack.json
@@ -1,4 +1,5 @@
 {
+  "project_name": "{{ cookiecutter.project_metadata.project_name }}",
   "framework": "{{ cookiecutter.framework }}",
   "agentstack_version": "{{ cookiecutter.project_metadata.agentstack_version }}",
   "template": "{{ cookiecutter.project_metadata.template }}",
diff --git a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py
index c8c347d4..6f6d3dfc 100644
--- a/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py
+++ b/agentstack/frameworks/templates/crewai/{{cookiecutter.project_metadata.project_slug}}/src/main.py
@@ -4,15 +4,23 @@
 import agentstack
 import agentops
 
-agentops.init(default_tags=agentstack.get_tags())
+agentops.init(default_tags=agentstack.get_tags(), skip_auto_end_session=True, auto_start_session=False)
 
 instance = {{cookiecutter.project_metadata.project_name|replace('-', '')|replace('_', '')|capitalize}}Crew().crew()
 
-def run():
+def run() -> [str, str]:
     """
     Run the agent.
+    Returns:
+        A Tuple: (The output of running the agent, agentops session_id)
     """
-    instance.kickoff(inputs=agentstack.get_inputs())
+    session = agentops.start_session()
+    try:
+        result = instance.kickoff(inputs=agentstack.get_inputs())
+        session.end_session(end_state="Success")
+        return result.raw, str(session.session_id)
+    except:
+        session.end_session(end_state="Fail")
 
 
 def train():
diff --git a/agentstack/log.py b/agentstack/log.py
index 9ee0a3d6..1c7df437 100644
--- a/agentstack/log.py
+++ b/agentstack/log.py
@@ -24,7 +24,6 @@
 """
 
 from typing import IO, Optional, Callable
-import os, sys
 import io
 import logging
 from agentstack import conf
diff --git a/agentstack/main.py b/agentstack/main.py
index 3139a9a5..094f113e 100644
--- a/agentstack/main.py
+++ b/agentstack/main.py
@@ -1,3 +1,4 @@
+import asyncio
 import sys
 import argparse
 import webbrowser
@@ -8,17 +9,19 @@
     init_project,
     list_tools,
     add_tool,
-    remove_tool,
     add_agent,
     add_task,
     run_project,
     export_template,
+    # serve_project
 )
+from agentstack.cli.cli import serve_project
 from agentstack.telemetry import track_cli_command, update_telemetry
 from agentstack.utils import get_version, term_color
 from agentstack import generation
 from agentstack import repo
 from agentstack.update import check_for_updates
+from agentstack.deploy import deploy
 
 
 def _main():
@@ -36,7 +39,7 @@ def _main():
         action="store_true",
     )
     global_parser.add_argument(
-        "--no-git", 
+        "--no-git",
         help="Disable automatic git commits of changes to your project.",
         dest="no_git",
         action="store_true",
@@ -149,13 +152,21 @@ def _main():
     )
     tools_remove_parser.add_argument("name", help="Name of the tool to remove")
 
+    # 'export'
     export_parser = subparsers.add_parser(
         'export', aliases=['e'], help='Export your agent as a template', parents=[global_parser]
     )
     export_parser.add_argument('filename', help='The name of the file to export to')
 
+    # 'update'
     update = subparsers.add_parser('update', aliases=['u'], help='Check for updates', parents=[global_parser])
 
+    # 'deploy'
+    deploy_ = subparsers.add_parser('deploy', aliases=['d'], help='Deploy your agent to AgentStack.sh', parents=[global_parser])
+
+    # 'serve' command
+    serve_parser = subparsers.add_parser('serve', aliases=['s'], help='Serve your agent')
+
     # Parse known args and store unknown args in extras; some commands use them later on
     args, extra_args = parser.parse_known_args()
 
@@ -206,6 +217,12 @@ def _main():
         # inside project dir commands only
         elif args.command in ["run", "r"]:
             run_project(command=args.function, cli_args=extra_args)
+        elif args.command in ['deploy', 'd']:
+            conf.assert_project()
+            asyncio.run(deploy())
+        elif args.command in ['serve', 's']:
+            conf.assert_project()
+            serve_project()
         elif args.command in ['generate', 'g']:
             if args.generate_command in ['agent', 'a']:
                 add_agent(
diff --git a/agentstack/serve/Dockerfile b/agentstack/serve/Dockerfile
new file mode 100644
index 00000000..3bcf8028
--- /dev/null
+++ b/agentstack/serve/Dockerfile
@@ -0,0 +1,29 @@
+FROM python:3.12-slim-bookworm
+RUN rm /bin/sh && ln -s /bin/bash /bin/sh
+
+WORKDIR /app
+
+RUN apt update && apt install -y git gcc build-essential tree
+RUN apt clean && rm -rf /var/lib/apt/lists/*
+
+# Install uv
+RUN pip install --no-cache-dir uv
+
+# Copy everything since we're installing local package 3
+COPY . .
+
+RUN uv pip install --system psutil
+RUN uv pip install --system flask
+RUN uv pip install --system gunicorn
+RUN uv pip install --system git+https://github.com/AgentOps-AI/AgentStack.git@deploy-command
+RUN mkdir -p src
+RUN cp /usr/local/lib/python3.12/site-packages/agentstack/serve/serve.py ./src
+RUN cp /usr/local/lib/python3.12/site-packages/agentstack/serve/gunicorn.config.py ./src
+RUN uv pip install --system .
+RUN rm -rf /root/.cache/uv/* /root/.cache/pip/* /tmp/*
+
+# Expose the port
+EXPOSE 6969
+
+# Use Gunicorn with config file
+CMD ["gunicorn", "--config", "src/gunicorn.config.py", "src.serve:app"]
\ No newline at end of file
diff --git a/agentstack/serve/gunicorn.config.py b/agentstack/serve/gunicorn.config.py
new file mode 100644
index 00000000..a6c32322
--- /dev/null
+++ b/agentstack/serve/gunicorn.config.py
@@ -0,0 +1,18 @@
+import multiprocessing
+import os
+
+bind = f"0.0.0.0:{os.getenv('PORT') or '6969'}"
+workers = 1
+threads = 1
+worker_class = "sync"
+max_requests = 1
+max_requests_jitter = 0
+timeout = 300
+keepalive = 2
+worker_connections = 1
+errorlog = "-"
+accesslog = "-"
+capture_output = True
+
+def post_worker_init(worker):
+    worker.nr = 1
\ No newline at end of file
diff --git a/agentstack/serve/serve.py b/agentstack/serve/serve.py
new file mode 100644
index 00000000..aac6bcc5
--- /dev/null
+++ b/agentstack/serve/serve.py
@@ -0,0 +1,178 @@
+import importlib
+import sys
+from pathlib import Path
+from urllib.parse import urlparse
+
+from dotenv import load_dotenv
+from agentstack import conf, frameworks, inputs, log
+from agentstack.exceptions import ValidationError
+from agentstack.utils import verify_agentstack_project
+# TODO: move this to not cli, but cant be utils due to circular import
+from agentstack.cli.run import format_friendly_error_message
+from flask import Flask, request, jsonify
+import requests
+from typing import Dict, Any, Optional, Tuple
+import os
+
+MAIN_FILENAME: Path = Path("src/main.py")
+MAIN_MODULE_NAME = "main"
+
+load_dotenv(dotenv_path="/app/.env")
+app = Flask(__name__)
+
+current_webhook_url = None
+
+def call_webhook(webhook_url: str, data: Dict[str, Any]) -> None:
+    """Send results to the specified webhook URL."""
+    try:
+        response = requests.post(webhook_url, json=data)
+        response.raise_for_status()
+    except requests.exceptions.RequestException as e:
+        app.logger.error(f"Webhook call failed: {str(e)}")
+        raise
+
+@app.route("/health", methods=["GET"])
+def health():
+    return "Agent Server Up"
+
+@app.route('/process', methods=['POST'])
+def process_agent():
+    global current_webhook_url
+
+    request_data = None
+    try:
+        request_data = request.get_json()
+
+        if not request_data or 'webhook_url' not in request_data:
+            result, message = validate_url(request_data.get("webhook_url"))
+            if not result:
+                return jsonify({'error': f'Invalid webhook_url in request: {message}'}), 400
+
+        if not request_data or 'inputs' not in request_data:
+            return jsonify({'error': 'Missing input data in request'}), 400
+
+        current_webhook_url = request_data.pop('webhook_url')
+
+        return jsonify({
+            'status': 'accepted',
+            'message': 'Agent process started'
+        }), 202
+
+    except Exception as e:
+        error_message = str(e)
+        app.logger.error(f"Error processing request: {error_message}")
+        return jsonify({
+            'status': 'error',
+            'error': error_message
+        }), 500
+
+    finally:
+        if current_webhook_url:
+            try:
+                result, session_id = run_project(api_inputs=request_data.get('inputs'))
+                call_webhook(current_webhook_url, {
+                    'status': 'success',
+                    'result': result,
+                    'session_id': session_id
+                })
+            except Exception as e:
+                error_message = str(e)
+                app.logger.error(f"Error in process: {error_message}")
+                try:
+                    call_webhook(current_webhook_url, {
+                        'status': 'error',
+                        'error': error_message
+                    })
+                except:
+                    app.logger.error("Failed to send error to webhook")
+            finally:
+                current_webhook_url = None
+
+def run_project(command: str = 'run', api_args: Optional[Dict[str, str]] = None,
+                api_inputs: Optional[Dict[str, str]] = None):
+    """Validate that the project is ready to run and then run it."""
+    verify_agentstack_project()
+
+    if conf.get_framework() not in frameworks.SUPPORTED_FRAMEWORKS:
+        raise ValidationError(f"Framework {conf.get_framework()} is not supported by agentstack.")
+
+    try:
+        frameworks.validate_project()
+    except ValidationError as e:
+        raise e
+
+    for key, value in api_inputs.items():
+        inputs.add_input_for_run(key, value)
+
+    load_dotenv(Path.home() / '.env')  # load the user's .env file
+    load_dotenv(conf.PATH / '.env', override=True)  # load the project's .env file
+
+    try:
+        log.notify("Running your agent...")
+        project_main = _import_project_module(conf.PATH)
+        return getattr(project_main, command)()
+    except ImportError as e:
+        raise ValidationError(f"Failed to import AgentStack project at: {conf.PATH.absolute()}\n{e}")
+    except Exception as e:
+        raise Exception(format_friendly_error_message(e))
+
+def _import_project_module(path: Path):
+    """Import `main` from the project path."""
+    spec = importlib.util.spec_from_file_location(MAIN_MODULE_NAME, str(path / MAIN_FILENAME))
+
+    assert spec is not None
+    assert spec.loader is not None
+
+    project_module = importlib.util.module_from_spec(spec)
+    sys.path.insert(0, str((path / MAIN_FILENAME).parent))
+    spec.loader.exec_module(project_module)
+    return project_module
+
+def validate_url(url: str) -> Tuple[bool, str]:
+    """Validates a URL and returns a tuple of (is_valid, error_message)."""
+    if not url:
+        return False, "URL cannot be empty"
+
+    try:
+        result = urlparse(url)
+
+        if not result.scheme:
+            return False, "Missing protocol (e.g., http:// or https://)"
+
+        if not result.netloc:
+            return False, "Missing domain name"
+
+        if result.scheme not in ['http', 'https']:
+            return False, f"Invalid protocol: {result.scheme}"
+
+        if '.' not in result.netloc:
+            return False, "Invalid domain format"
+
+        return True, ""
+
+    except Exception as e:
+        return False, f"Invalid URL format: {str(e)}"
+
+
+def get_waitress_config():
+    return {
+        'host': '0.0.0.0',
+        'port': int(os.getenv('PORT') or '6969'),
+        'threads': 1,                      # Similar to Gunicorn threads
+        'connection_limit': 1,             # Similar to worker_connections
+        'channel_timeout': 300,            # Similar to timeout
+        'cleanup_interval': 2,             # Similar to keepalive
+        'log_socket_errors': True,
+        'max_request_body_size': 1073741824,  # 1GB
+        'clear_untrusted_proxy_headers': True
+    }
+
+if __name__ == '__main__':
+    port = int(os.environ.get('PORT', 6969))
+    print("🚧 Running your agent on a development server")
+    print(f"Send agent requests to http://localhost:{port}")
+    print("Learn more about agent requests at https://docs.agentstack.sh/")  # TODO: add docs for this
+
+    app.run(host='0.0.0.0', port=port)
+else:
+    print("Starting production server with Gunicorn")
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 5cbc9bc8..7ed57b34 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "agentstack"
-version = "0.3.3"
+version = "0.3.5"
 description = "The fastest way to build robust AI agents"
 authors = [
     { name="Braelyn Boynton", email="bboynton97@gmail.com" },
@@ -33,6 +33,7 @@ dependencies = [
     "uv>=0.5.6",
     "tomli>=2.2.1",
     "gitpython>=3.1.44",
+    "websockets>=14.2"
 ]
 
 [project.optional-dependencies]
diff --git a/tests/test_cli_spinner.py b/tests/test_cli_spinner.py
new file mode 100644
index 00000000..b0bad8a4
--- /dev/null
+++ b/tests/test_cli_spinner.py
@@ -0,0 +1,116 @@
+import unittest
+from unittest.mock import patch, MagicMock, call
+import time
+import threading
+from io import StringIO
+from agentstack.cli.spinner import Spinner
+
+
+class TestSpinner(unittest.TestCase):
+    def setUp(self):
+        """Set up test cases."""
+        self.mock_stdout_patcher = patch('sys.stdout', new_callable=StringIO)
+        self.mock_stdout = self.mock_stdout_patcher.start()
+
+        self.mock_terminal_patcher = patch('shutil.get_terminal_size')
+        self.mock_terminal = self.mock_terminal_patcher.start()
+        self.mock_terminal.return_value = MagicMock(columns=80)
+
+        # Patch the log module where Spinner is importing it
+        self.mock_log_patcher = patch('agentstack.cli.spinner.log')
+        self.mock_log = self.mock_log_patcher.start()
+
+    def tearDown(self):
+        """Clean up after tests."""
+        self.mock_stdout_patcher.stop()
+        self.mock_terminal_patcher.stop()
+        self.mock_log_patcher.stop()
+
+    def test_spinner_initialization(self):
+        """Test spinner initialization."""
+        spinner = Spinner(message="Test")
+        self.assertEqual(spinner.message, "Test")
+        self.assertEqual(spinner.delay, 0.1)
+        self.assertFalse(spinner.running)
+        self.assertIsNone(spinner.spinner_thread)
+        self.assertIsNone(spinner.start_time)
+
+    def test_context_manager(self):
+        """Test spinner works as context manager."""
+        with Spinner("Test") as spinner:
+            self.assertTrue(spinner.running)
+            self.assertTrue(spinner.spinner_thread.is_alive())
+            time.sleep(0.2)
+
+        self.assertFalse(spinner.running)
+        self.assertFalse(spinner.spinner_thread.is_alive())
+
+    def test_clear_and_log(self):
+        """Test clear_and_log functionality."""
+        test_message = "Test log message"
+        with Spinner("Test") as spinner:
+            spinner.clear_and_log(test_message)
+            time.sleep(0.2)
+
+        # Verify log.success was called with the message
+        self.mock_log.success.assert_called_once_with(test_message)
+
+    def test_concurrent_logging(self):
+        """Test thread safety of logging while spinner is running."""
+        messages = ["Message 0", "Message 1", "Message 2"]
+
+        def log_messages(spinner):
+            for msg in messages:
+                spinner.clear_and_log(msg)
+                time.sleep(0.1)
+
+        with Spinner("Test") as spinner:
+            thread = threading.Thread(target=log_messages, args=(spinner,))
+            thread.start()
+            thread.join()
+
+        # Verify all messages were logged
+        self.assertEqual(self.mock_log.success.call_count, len(messages))
+        self.mock_log.success.assert_has_calls([call(msg) for msg in messages])
+
+    def test_thread_cleanup(self):
+        """Test proper thread cleanup after stopping."""
+        spinner = Spinner("Test")
+        spinner.start()
+        time.sleep(0.2)
+        spinner.clear_and_log("Test message")
+        spinner.stop()
+
+        # Give thread time to clean up
+        time.sleep(0.1)
+        self.assertFalse(spinner.running)
+        self.assertFalse(spinner.spinner_thread.is_alive())
+        self.mock_log.success.assert_called_once_with("Test message")
+
+    def test_rapid_message_updates(self):
+        """Test spinner handles rapid message updates and logging."""
+        messages = [f"Message {i}" for i in range(5)]
+        with Spinner("Initial") as spinner:
+            for msg in messages:
+                spinner.update_message(msg)
+                spinner.clear_and_log(f"Logged: {msg}")
+                time.sleep(0.05)
+
+        # Verify all messages were logged
+        self.assertEqual(self.mock_log.success.call_count, len(messages))
+        self.mock_log.success.assert_has_calls([
+            call(f"Logged: {msg}") for msg in messages
+        ])
+
+    @patch('time.time')
+    def test_elapsed_time_display(self, mock_time):
+        """Test elapsed time is displayed correctly."""
+        mock_time.side_effect = [1000, 1001, 1002]  # Mock timestamps
+
+        with Spinner("Test") as spinner:
+            spinner.clear_and_log("Time check")
+            time.sleep(0.2)
+            output = self.mock_stdout.getvalue()
+            self.assertIn("[1.0s]", output)
+            self.mock_log.success.assert_called_once_with("Time check")
+
diff --git a/tests/test_deploy.py b/tests/test_deploy.py
new file mode 100644
index 00000000..57e74603
--- /dev/null
+++ b/tests/test_deploy.py
@@ -0,0 +1,149 @@
+import os
+import unittest
+from unittest.mock import patch, Mock, mock_open
+from pathlib import Path
+import tempfile
+import zipfile
+
+from agentstack.deploy import deploy, collect_files, create_zip_in_memory, get_project_id, load_pyproject
+
+class TestDeployFunctions(unittest.TestCase):
+    def setUp(self):
+        # Common setup for tests
+        self.bearer_token = "test_token"
+        self.project_id = "test_project_123"
+
+    @patch('agentstack.deploy.get_stored_token')
+    @patch('agentstack.deploy.login')
+    @patch('agentstack.deploy.get_project_id')
+    @patch('agentstack.deploy.requests.post')
+    @patch('agentstack.deploy.webbrowser.open')
+    def test_deploy_success(self, mock_browser, mock_post, mock_get_project, mock_login, mock_token):
+        # Setup mocks
+        mock_token.return_value = self.bearer_token
+        mock_get_project.return_value = self.project_id
+        mock_post.return_value.status_code = 200
+
+        # Call deploy function
+        deploy()
+
+        # Verify API call
+        mock_post.assert_called_once()
+        self.assertIn('/deploy/build', mock_post.call_args[0][0])
+        self.assertIn('Bearer test_token', mock_post.call_args[1]['headers']['Authorization'])
+
+        # Verify browser opened
+        mock_browser.assert_called_once_with(f"http://localhost:5173/project/{self.project_id}")
+
+    @patch('agentstack.deploy.get_stored_token')
+    @patch('agentstack.deploy.login')
+    def test_deploy_no_auth(self, mock_login, mock_token):
+        # Setup mocks for failed authentication
+        mock_token.return_value = None
+        mock_login.return_value = False
+
+        # Call deploy function
+        deploy()
+
+        # Verify login was attempted
+        mock_login.assert_called_once()
+        mock_token.assert_called_once()
+
+    def test_collect_files(self):
+        # Create temporary directory structure
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create test files
+            Path(tmpdir, 'test.py').touch()
+            Path(tmpdir, 'test.toml').touch()
+            Path(tmpdir, 'ignore.txt').touch()
+
+            # Create subdirectory with files
+            subdir = Path(tmpdir, 'subdir')
+            subdir.mkdir()
+            Path(subdir, 'sub.py').touch()
+
+            # Create excluded directory
+            venv = Path(tmpdir, '.venv')
+            venv.mkdir()
+            Path(venv, 'venv.py').touch()
+
+            # Collect files
+            files = collect_files(tmpdir, ('.py', '.toml'))
+
+            # Verify results
+            file_names = {f.name for f in files}
+            self.assertIn('test.py', file_names)
+            self.assertIn('test.toml', file_names)
+            self.assertIn('sub.py', file_names)
+            self.assertNotIn('ignore.txt', file_names)
+            self.assertNotIn('venv.py', file_names)
+
+    @patch('agentstack.deploy.requests.post')
+    def test_get_project_id_create_new(self, mock_post):
+        # Mock successful project creation
+        mock_post.return_value.status_code = 200
+        mock_post.return_value.json.return_value = {'id': 'new_project_123'}
+
+        # Mock ConfigFile
+        with patch('agentstack.deploy.ConfigFile') as mock_config:
+            mock_config.return_value.hosted_project_id = None
+            mock_config.return_value.project_name = 'test_project'
+
+            project_id = get_project_id()
+
+            self.assertEqual(project_id, 'new_project_123')
+            mock_post.assert_called_once()
+
+    def test_load_pyproject(self):
+        # Test with valid pyproject.toml
+        mock_toml_content = b'''
+        [project]
+        name = "test-project"
+        version = "1.0.0"
+        '''
+
+        mock_file = mock_open(read_data=mock_toml_content)
+        with patch('builtins.open', mock_file):
+            with patch('os.path.exists') as mock_exists:
+                mock_exists.return_value = True
+                result = load_pyproject()
+
+                # Verify file was opened in binary mode
+                mock_file.assert_called_once_with("pyproject.toml", "rb")
+
+                self.assertIsNotNone(result)
+                self.assertIn('project', result)
+                self.assertEqual(result['project']['name'], 'test-project')
+
+    @patch('agentstack.deploy.log.error')
+    def test_create_zip_in_memory(self, mock_log):
+        # Create temporary test files
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a test directory structure
+            test_dir = Path(tmpdir)
+            test_file = test_dir / 'test.py'
+            test_file.write_text('print("test")')
+
+            # Create mock spinner
+            mock_spinner = Mock()
+
+            # Test zip creation
+            # We'll need to change to the temp directory to maintain correct relative paths
+            original_dir = Path.cwd()
+            try:
+                os.chdir(tmpdir)
+                # Now use relative path for the file
+                files = [Path('test.py')]
+                zip_file = create_zip_in_memory(files, mock_spinner)
+
+                # Verify zip contents
+                with zipfile.ZipFile(zip_file, 'r') as zf:
+                    self.assertIn('test.py', zf.namelist())
+                    # Additional verification of zip contents
+                    self.assertEqual(len(zf.namelist()), 1)
+                    with zf.open('test.py') as f:
+                        content = f.read().decode('utf-8')
+                        self.assertEqual(content, 'print("test")')
+            finally:
+                # Make sure we always return to the original directory
+                os.chdir(original_dir)