# Project export: Crewly

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: The agentic back office for small trade businesses.
- Devpost: https://devpost.com/software/crewly
- GitHub: https://github.com/wndyx/Crewly
- Video: https://www.youtube.com/embed/b8cB76KrPPg?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — Lewin Bobda (8 commits), Wendy Xiao (8 commits), SiddhBamb (7 commits), Chelsea W. (2 commits)

## Devpost submission (written by the team)

### Inspiration

Tradespeople lose hours every week to “back office” work: calls/texts, finding leads, checking whether a job is even feasible, and tracking supplies. We wanted to build something that makes a solo contractor feel like they have an office manager so they can spend their time on the work they’re actually paid (and proud) to do.

### What it does

Crewly is an AI-powered operations layer for blue-collar pros. Our framework: Poke/Message onboard tradesperson orchestrates everything Voice Agent handles incoming calls from potential clients schedules appointments gives quotes based on materials and situation Inventory Agent keeps track of current inventory with cost looks at cheapest prices using BrightData reorders low inventory using BrowserBase Stagehand Outreach Agent looks on sites like Nextdoor and Craigslist to get leads on potential clients using BrowserBase Stagehand Marketing Agent using OpenAI API to generate business cards and brochures Logging Agent keeps track of client correspondence and customer satisfaction Mapping Agent feasibility and ordering of tasks tracking scope of outreach Website Agent uses Vercel and OpenAI API to spawn and deploy company websites Document Agent generates and signs documents

### How we built it

We built Crewly as a tool-driven agent system: a chat interface orchestrates an MCP tool server that exposes business actions like feasibility checks, pricing floor calculation, and inventory logic. We used structured tool inputs/outputs so the agent can reliably chain steps: collect job details → validate feasibility → estimate a minimum viable price → propose next actions. We also added Modal configuration within our MCP to allow for our agents to deploy secure and concurrent sandboxes to run code in.

### Challenges we ran into

Tool wiring and reliability: making sure the agent passes the right units/types (miles vs meters, hours vs minutes). Environment + auth issues: handling missing API keys and avoiding circular imports in a multi-module tools package. Especially in handling environment with fragile dependencies, it was easy to run into issues when one part of the workflow was not properly set up. Keeping outputs safe and consistent: ensuring tools return predictable outputs.

### Accomplishments we're proud of

A working end-to-end flow from a single message to actionable ops steps. Modular tools that are easy to extend (distance validation, concurrent sandbox runs, inventory hooks). Clean tool semantics + documentation so the agent behaves more like a real assistant and less like a demo.

### What we learned

Tool design matters as much as the model: clear names, tight schemas, and good docstrings dramatically improve correctness. “Ops” is a chain, not a single feature—small automations compound into real time saved. Debuggability is everything: structured outputs and health checks make iteration fast.

### What's next

Generalize beyond trades into any contractor's staff team to lead intake, quoting, scheduling, follow-ups, lightweight CRM, and parts/procurement plus tighter integrations (maps, calendars, messaging) so the AI can run more of the workflow autonomously.

## README (from the GitHub repository)

# 🛠️ Crewly

## AI Operations Copilot for Tradespeople

Crewly is an AI-powered operations layer built for blue-collar professionals — plumbers, electricians, HVAC technicians, pest control providers, landscapers, and independent contractors.

Tradespeople spend too much time answering calls, responding to texts, searching for jobs, checking feasibility, tracking supplies, and ordering parts. Crewly automates the back office so tradespeople can focus on the work.

---

## 🚀 What Crewly Does (MVP)

Crewly is a modular, multi-agent system that helps trades professionals:

- 📞 Handle incoming calls and texts (AI receptionist)
- 🔎 Proactively find new job opportunities online (lead discovery)
- 🗺️ Check travel feasibility before accepting jobs (distance + ETA)
- 📦 Track inventory and material usage (auto-deplete supplies)
- 🌐 Compare supplier pricing and find parts (browser reminders + search)
- 📱 Perform outreach via messaging agents (follow-ups + booking)

---

## 🧠 How It Works

Crewly uses an orchestration layer that coordinates specialized agents and MCP tools.

Tradesperson  
↓  
Crewly Orchestrator  
↓  
Specialized Agents + MCP Tools  


Each capability is implemented as an atomic, independently callable tool.


## Detected evidence (automated analysis)

Indexed codebase: 26 recognized source files, 152 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- TypeScript (language) — detected in the code
- OpenAI (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (34 of 34)

```
.gitignore
poke-mcp/agents/__init__.py
poke-mcp/agents/inventory_manager.py
poke-mcp/agents/negotiator.py
poke-mcp/agents/schedule_query.py
poke-mcp/api.py
poke-mcp/crewly.db
poke-mcp/frontend/assets/app.js
poke-mcp/frontend/assets/styles.css
poke-mcp/frontend/index.html
poke-mcp/README.md
poke-mcp/requirements.txt
poke-mcp/server.py
poke-mcp/start.sh
poke-mcp/tests/basic_function_calls.py
poke-mcp/tests/inventory.db
poke-mcp/tools/__init__.py
poke-mcp/tools/call_logs.py
poke-mcp/tools/distance.py
poke-mcp/tools/inventory.py
poke-mcp/tools/price_calculator.py
poke-mcp/tools/reach.py
poke-mcp/tools/schedule_clients.py
poke-mcp/webhook.py
README.md
script.txt
website-spawn-mcp/.gitignore
website-spawn-mcp/package.json
website-spawn-mcp/README.md
website-spawn-mcp/scripts/call-tool.ts
website-spawn-mcp/scripts/delete-tool.ts
website-spawn-mcp/src/index.ts
website-spawn-mcp/src/spawn.ts
website-spawn-mcp/tsconfig.json
```

### Dependencies

- poke-mcp/requirements.txt: anthropic@>=0.50.0, claude_agent_sdk@>=0.1.0, claude-agent-sdk@>=0.1.0, fastapi@>=0.115.0, fastmcp@>=2.0.0, imessage-reader@>=0.2.9, mac-imessage, modal@>=0.73.0, pyobjc-framework-AddressBook, requests@>=2.28.0, uvicorn@>=0.32.0
- website-spawn-mcp/package.json: @modelcontextprotocol/sdk@^1.26.0, @types/node@^22.12.0, dotenv@^16.4.7, tsx@^4.19.2, typescript@^5.0.0, zod@^3.23.8

### Recent commits (newest first)

- fixed some more tools, added call log history & webhook functionality for summary/end-of-call info transfer between agents, added mcp endpoints for call logs
- Merge branch 'frontend'
- stuffs
- implement into the mcp thing
- add auto website generator and deployer
- Merge branch 'frontend' of https://github.com/wndyx/TradeOS into frontend
- removed env
- fixed some frontend parts
- contact reader fix
- Fix imessage bug
- Imessage tooling
- Delete poke-mcp/myenv directory
- Delete poke-mcp/.env
- stuff
- start.sh
- removed the negotiate
- frontend
- fixed server.py
- made negotiator agent and added functionality to iventory manager
- distance tools

## Key source files (fetched from GitHub, selected and truncated for size)

### poke-mcp/requirements.txt

```
fastmcp>=2.0.0
modal>=0.73.0
requests>=2.28.0
claude-agent-sdk>=0.1.0
imessage-reader>=0.2.9
mac-imessage
pyobjc-framework-AddressBook
anthropic>=0.50.0
claude_agent_sdk>=0.1.0
fastapi>=0.115.0
uvicorn>=0.32.0

```

### website-spawn-mcp/package.json

```
{
  "name": "website-spawn-mcp",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts",
    "test:manual": "tsx scripts/call-tool.ts",
    "delete": "tsx scripts/delete-tool.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.26.0",
    "dotenv": "^16.4.7",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@types/node": "^22.12.0",
    "tsx": "^4.19.2",
    "typescript": "^5.0.0"
  }
}

```

### website-spawn-mcp/src/index.ts

```typescript
import { config } from "dotenv";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: join(__dirname, "..", ".env") });
import type { IncomingMessage } from "node:http";
import type { ServerResponse } from "node:http";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { spawnCompanyWebsite, deleteCompanyWebsite } from "./spawn.js";

const PORT = Number(process.env.PORT) || 8770;

function getServer(): McpServer {
  const server = new McpServer({
    name: "website-spawn-mcp",
    version: "1.0.0",
  });

  server.registerTool(
    "spawn_company_website",
    {
      title: "Spawn Company Website",
      description:
        "Generate a company landing page from a description, deploy it to Vercel, and return the live URL. Includes hero, services, testimonials, and contact with the given phone number.",
      inputSchema: {
        company_description: z
          .string()
          .describe("Short description of the company (used for hero copy and testimonials)"),
        company_name: z
          .string()
          .optional()
          .describe("Company name (used in title and hero; optional)"),
        services: z
          .string()
          .describe(
            "Comma- or newline-separated list of services to show on the page",
          ),
        phone_number: z
          .string()
          .describe("Phone number to display in the Contact Us section"),
      },
    },
    async ({
      company_description,
      company_name,
      services,
      phone_number,
    }) => {
      try {
        const { url } = await spawnCompanyWebsite({
          companyDescription: company_description,
          companyName: company_name,
          services,
          phoneNumber: phone_number,
        });
        return {
          content: [
            {
              type: "text" as const,
              text: JSON.stringify({ url, ok: true }),
            },
          ],
        };
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return {
          content: [
            {
              type: "text" as const,
              text: JSON.stringify({ ok: false, error: message }),
            },
          ],
          isError: true,
        };
      }
    },
  );

  server.registerTool(
    "delete_company_website",
    {
      title: "Delete Company Website",
      description:
        "Undeploy and delete a Vercel deployment. Pass the full URL (e.g. https://landing-xxx.vercel.app) or the deployment ID (dpl_xxx).",
      inputSchema: {
        url: z
          .string()
          .describe("The full deployment URL (e.g. https://landing-xxx.vercel.app) or Vercel deployment ID (dpl_...)"),
      },
    },
    async ({ url }) => {
      try {
        await deleteCompanyWebsite(url);
        return {
          content: [
            {
              type: "text" as const,
              text: JSON.stringify({ deleted: true, ok: true }),
            },
          ],
        };
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return {
          content: [
            {
              type: "text" as const,
              text: JSON.stringify({ ok: false, error: message }),
            },
          ],
          isError: true,
        };
      }
    },
  );

  return server;
}

const app = createMcpExpressApp();

app.post("/mcp", async (req: IncomingMessage & { body?: object }, res: ServerResponse) => {
  const server = getServer();
  try {
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body ?? undefined);
    res.on("close", () => {
      transport.close();
      server.close();
    });
  } catch (error) {
    console.error("MCP request error:", error);
    if (!res.headersSent) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(
        JSON.stringify({
          jsonrpc: "2.0",
          error: { code: -32603, message: "Internal server error" },
          id: null,
        }),
      );
    }
  }
});

app.get("/mcp", (_req: IncomingMessage, res: ServerResponse) => {
  res.writeHead(405).end(
    JSON.stringify({
      jsonrpc: "2.0",
      error: { code: -32000, message: "Method not allowed." },
      id: null,
    }),
  );
});

app.listen(PORT, () => {
  console.log(`Website Spawn MCP server listening on http://0.0.0.0:${PORT}`);
  console.log(`Endpoint: http://localhost:${PORT}/mcp`);
});

process.on("SIGINT", () => {
  process.exit(0);
});

```

### poke-mcp/server.py

```python
import json
import os
import shlex
import ssl
import asyncio
import inspect
import re
import sys
from typing import Any

try:
    import requests
except ImportError:
    requests = None

try:
    import modal
except ImportError:
    modal = None

from fastmcp import FastMCP

from tools.inventory import (
    list_inventory, 
    check_stock, 
    update_stock, 
    estimate_parts_needed,
    seed_db_for_industry
)

from tools.price_calculator import (
    set_pricing_params, 
    calculate_job_price
)
from agents.negotiator import negotiate_job_offer

from tools.distance import check_within_travel_limit

from tools.call_logs import (
    get_all_calls,
    get_revenue_stats,
    get_problematic_calls,
    get_appointment_stats,
)

INDUSTRY = "plumbing"

seed_db_for_industry(INDUSTRY)



def _configure_tls_ca_bundle() -> None:
    """
    Ensure HTTPS clients can find a CA bundle.
    """
    verify_paths = ssl.get_default_verify_paths()
    if verify_paths.cafile:
        return

    try:
        import certifi
    except Exception:
        return

    os.environ.setdefault("SSL_CERT_FILE", certifi.where())
    os.environ.setdefault("REQUESTS_CA_BUNDLE", certifi.where())


_configure_tls_ca_bundle()

try:
    import modal
except ImportError:
    modal = None

mcp = FastMCP("Modal Sandbox Tools")


def _expand_db_path(db_path: str) -> str:
    return os.path.expanduser(db_path.strip())


def _load_imessage_rows(db_path: str) -> list[tuple[Any, Any, Any, Any, Any, Any]]:
    from imessage_reader import fetch_data
    print("imessage reader: ")
    reader = fetch_data.FetchData(_expand_db_path(db_path))
    rows = reader.get_messages()
    return list(rows)


def _rows_to_payload(rows: list[tuple[Any, Any, Any, Any, Any, Any]]) -> list[dict[str, Any]]:
    payload: list[dict[str, Any]] = []
    for row in rows:
        payload.append(
            {
                "user_id": row[0],
                "message": row[1],
                "date": row[2],
                "service": row[3],
                "account": row[4],
                "is_from_me": bool(row[5]),
            }
        )
    return payload


def _rows_sorted_most_recent(
    rows: list[tuple[Any, Any, Any, Any, Any, Any]]
) -> list[tuple[Any, Any, Any, Any, Any, Any]]:
    """
    Sort rows by their date column in descending order (most recent first).
    """
    return sorted(rows, key=lambda row: str(row[2]), reverse=True)


def _load_contacts_rows() -> list[dict[str, Any]]:
    """
    Read contacts from macOS Contacts using PyObjC (AddressBook framework).
    """
    try:
        from AddressBook import (
            ABAddressBook,
            kABEmailProperty,
            kABFirstNameProperty,
            kABLastNameProperty,
            kABOrganizationProperty,
            kABPhoneProperty,
        )
    except ImportError as exc:
        raise ImportError(
            "PyObjC AddressBook bindings are not installed. Install `pyobjc-framework-AddressBook`."
        ) from exc

    def _clean(value: Any) -> str:
        if value is None:
            return ""
        return str(value).strip()

    def _multivalue_to_list(multivalue: Any) -> list[str]:
        if multivalue is None:
            return []
        values: list[str] = []
        try:
            count = int(multivalue.count())
        except Exception:
            return values

        for idx in range(count):
            try:
                item = multivalue.valueAtIndex_(idx)
            except Exception:
                continue
            text = _clean(item)
            if text:
                values.append(text)
        return values

    address_book = ABAddressBook.sharedAddressBook()
    if address_book is None:
        raise RuntimeError(
            "Unable to access macOS Contacts via AddressBook. Check Contacts permissions for this process."
        )

    contacts: list[dict[str, Any]] = []
    people = address_book.people() or []
    for person in people:
        first_name = _clean(person.valueForProperty_(kABFirstNameProperty))
        last_name = _clean(person.valueForProperty_(kABLastNameProperty))
        organization = _clean(person.valueForProperty_(kABOrganizationProperty))
        name = " ".join(part for part in [first_name, last_name] if part).strip()
        if not name:
            name = organization or "Unknown"

        phones = _multivalue_to_list(person.valueForProperty_(kABPhoneProperty))
        emails = _multivalue_to_list(person.valueForProperty_(kABEmailProperty))
        contacts.append(
            {
                "name": name,
                "phones": phones,
                "emails": emails,
            }
        )

    return contacts


def _parse_command(command: str) -> list[str]:
    parts = shlex.split(command)
    if not parts:
        raise ValueError("command cannot be empty")
    return parts


def _sandbox_from_id(sandbox_id: str) -> Any:
    if not sandbox_id.strip():
        raise ValueError("sandbox_id cannot be empty")

    if not hasattr(modal, "Sandbox") or not hasattr(modal.Sandbox, "from_id"):
        raise RuntimeError("This Modal SDK version does not support Sandbox.from_id.")

    return modal.Sandbox.from_id(sandbox_id.strip())


def _safe_json(data: dict[str, Any]) -> str:
    return json.dumps(data, indent=2)


def _run_sandbox_command(
    app_name: str,
    command_parts: list[str],
    timeout_seconds: int,
    idle_timeout_seconds: int,
    auto_terminate: bool,
    secrets: list[Any] | None = None,
    pip_install_packages: list[str] | None = None,
) -> dict[str, Any]:
    sandbox: Any = None
    process: Any = None
    stdout_text = ""
    stderr_text = ""

    try:
        app = modal.App.lookup(app_name, create_if_missing=True)
        create_kwargs: dict[str, Any] = {
            "app": app,
            "timeout": timeout_seconds,
            "idle_timeout": idle_timeout_seconds,
        }
        if secrets:
            create_kwargs["secrets"] = secrets

        sandbox = modal.S
[truncated — 29340 more characters]
```

### poke-mcp/frontend/assets/app.js

```javascript
const API = "/api";

function showResult(el, text, isError = false) {
  el.textContent = text;
  el.className = "result" + (isError ? " error" : " success");
  el.removeAttribute("hidden");
}

function clearResult(el) {
  el.textContent = "";
  el.className = "result";
}

async function fetchApi(path, options = {}) {
  const res = await fetch(API + path, {
    headers: { "Content-Type": "application/json", ...options.headers },
    ...options,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.detail || data.message || res.statusText);
  return data;
}

// --- Tabs ---
function showTab(id) {
  document.querySelectorAll(".tab").forEach((t) => {
    t.classList.toggle("active", t.dataset.tab === id);
    t.setAttribute("aria-selected", t.dataset.tab === id ? "true" : "false");
  });
  document.querySelectorAll(".panel").forEach((p) => {
    const isActive = p.id === "panel-" + id;
    p.classList.toggle("active", isActive);
    p.hidden = !isActive;
  });
}

document.querySelectorAll(".tab").forEach((tab) => {
  tab.addEventListener("click", () => {
    const id = tab.dataset.tab;
    showTab(id);
    if (id === "reach") loadReachMap();
  });
});

// Card link "View all" -> switch tab
document.querySelectorAll("[data-goto-tab]").forEach((link) => {
  link.addEventListener("click", (e) => {
    e.preventDefault();
    showTab(link.dataset.gotoTab);
    if (link.dataset.gotoTab === "inventory") loadInventory();
  });
});

// --- Reach map (commute range + red markers, stored) ---
let reachMap = null;
let reachCircle = null;
let reachMarkers = [];
let reachConfig = null;

const MILES_TO_METERS = 1609.34;

function loadReachMap() {
  const placeholder = document.getElementById("reach-map-placeholder");
  const noKeyMsg = document.getElementById("reach-no-key-msg");
  if (!placeholder || !noKeyMsg) return;

  placeholder.classList.remove("hidden");
  placeholder.textContent = "Loading…";
  noKeyMsg.hidden = true;
  document.getElementById("reach-map-load-error")?.remove();

  fetchApi("/reach/config")
    .then((config) => {
      reachConfig = config;
      if (!config.google_maps_api_key) {
        placeholder.textContent = "No API key configured.";
        noKeyMsg.hidden = false;
        return;
      }
      if (window.google && window.google.maps) {
        initReachMapWithConfig(config);
        return;
      }
      placeholder.textContent = "Loading map…";
      let loaded = false;
      window.onReachMapLoaded = () => {
        if (loaded) return;
        loaded = true;
        initReachMapWithConfig(config);
        placeholder.classList.add("hidden");
      };
      const script = document.createElement("script");
      script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(config.google_maps_api_key)}&callback=onReachMapLoaded`;
      script.async = true;
      script.defer = true;
      script.onerror = () => {
        if (loaded) return;
        placeholder.textContent = "Map script failed to load.";
        showReachMapFixMessage(placeholder);
      };
      document.head.appendChild(script);
      setTimeout(() => {
        if (!loaded && !window.google) {
          placeholder.textContent = "Map script timed out.";
          showReachMapFixMessage(placeholder);
        }
      }, 15000);
    })
    .catch(() => {
      placeholder.textContent = "Failed to load config.";
    });
}

function showReachMapFixMessage(insertBefore) {
  const origin = window.location.origin || "http://localhost:8000";
  const div = document.createElement("div");
  div.id = "reach-map-load-error";
  div.className = "reach-map-fix-msg";
  div.innerHTML = `
    <p><strong>To fix:</strong></p>
    <ul>
      <li>Enable <strong>Maps JavaScript API</strong> in <a href="https://console.cloud.google.com/apis/library/maps-backend.googleapis.com" target="_blank" rel="noopener">Google Cloud Console</a>.</li>
      <li>Under API key restrictions, add this origin to <strong>HTTP referrers</strong>: <code>${escapeHtml(origin)}</code> (and <code>${escapeHtml(origin)}/</code>).</li>
      <li>Ensure <strong>billing</strong> is enabled for the project (free tier is enough).</li>
    </ul>
  `;
  insertBefore.parentNode.insertBefore(div, insertBefore.nextSibling);
}

function initReachMapWithConfig(config) {
  const mapEl = document.getElementById("reach-map");
  const placeholder = document.getElementById("reach-map-placeholder");
  if (!mapEl || !config) return;

  const center = { lat: config.map_center.lat, lng: config.map_center.lng };
  reachMap = new google.maps.Map(mapEl, {
    center,
    zoom: 10,
    mapTypeControl: true,
    fullscreenControl: true,
  });

  // If Google shows "Oops! Something went wrong" (key/referrer/billing), detect and show fix
  setTimeout(() => {
    const text = (mapEl.innerText || mapEl.textContent || "").toLowerCase();
    if (text.includes("something went wrong") || text.includes("didn't load google maps")) {
      if (reachMap) reachMap = null;
      if (reachCircle) reachCircle = null;
      reachMarkers = [];
      mapEl.innerHTML = "";
      placeholder.classList.remove("hidden");
      placeholder.textContent = "Google Maps could not load (key or referrer issue).";
      showReachMapFixMessage(placeholder);
    }
  }, 4000);

  const radiusMeters = config.max_radius_miles * MILES_TO_METERS;
  reachCircle = new google.maps.Circle({
    map: reachMap,
    center,
    radius: radiusMeters,
    strokeColor: "#4a90b8",
    strokeOpacity: 0.8,
    strokeWeight: 2,
    fillColor: "#4a90b8",
    fillOpacity: 0.15,
  });

  reachMarkers = [];
  (config.markers || []).forEach((m) => addReachMarkerToMap(m));

  reachMap.addListener("click", (e) => {
    const lat = e.latLng.lat();
    const lng = e.latLng.lng();
    const label = window.prompt("Label (optional):", "") || "";
    fetchApi("/reach/markers", {
      method: "POST",
      body: JSON.stringify({ lat, lng, label }),
    })
      .then((created) => {
        ad
[truncated — 11124 more characters]
```

### poke-mcp/start.sh

```shell
#!/bin/bash

# Function to clean up background processes on exit (Ctrl+C)
cleanup() {
    echo "Stopping all processes..."
    kill $(jobs -p)
    exit
}

# Trap the interrupt signal (Ctrl+C) to run the cleanup function
trap cleanup SIGINT

echo "🚀 Starting TradeOS Voice & Text Infrastructure..."

# 1. Start the MCP Server (Localhost:8765)
python server.py & uvicorn api:app --reload --port 8000 &
sleep 2 # Wait for server to initialize

# 2. Start the ngrok tunnel for Vapi (Public URL)
ngrok http 8765 &

# 3. Start the Poke tunnel for iMessage
npx @interactionco/poke mcp dev --port 8765 --name "my-mcp-server" &

# Keep the script running so the background processes stay alive
wait
```

### poke-mcp/webhook.py

```python
# webhook.py
import uvicorn
from fastapi import FastAPI, Request
from tools.call_logs import log_call_from_webhook

try:
    import mac_imessage
except ImportError:
    mac_imessage = None

app = FastAPI()

@app.post("/vapi-webhook")
async def handle_vapi_webhook(request: Request):
    """
    Listens for Vapi's 'End of Call' report, logs to DB, and texts the user a summary.
    """
    data = await request.json()
    message_type = data.get("message", {}).get("type")

    if message_type == "end-of-call-report":
        msg = data["message"]
        call = msg.get("call", {})
        artifact = msg.get("artifact", {})
        customer = msg.get("customer", {})
        structured_outputs = artifact.get("structuredOutputs", {})

        customer_number = customer.get("number")
        call_id = call.get("id", "unknown")

        # --- 1. Log everything to the database ---
        log_call_from_webhook(data)

        # --- 2. Print all structured outputs ---
        print(f"\n📞 CALL ENDED | {call_id} | {customer_number}")
        print(f"   Cost: ${call.get('cost', 0):.2f} | Ended: {call.get('endedReason', 'unknown')}")
        for output in structured_outputs.values():
            print(f"   📝 {output['name']}: {output['result']}")

        # --- 3. Build summary text from structured outputs ---
        def _get(name):
            for o in structured_outputs.values():
                if o.get("name") == name:
                    return o.get("result")
            return None

        call_summary = _get("Call Summary") or "No summary available."
        sentiment = _get("Customer Sentiment") or "unknown"
        appointment_booked = _get("Appointment Booked")
        issue_type = _get("Issue Type") or "general"
        booking = _get("Booking Details") or {}
        booking_date = booking.get("appointmentDate", "") if isinstance(booking, dict) else ""
        booking_time = booking.get("appointmentTime", "") if isinstance(booking, dict) else ""

        # --- 4. Send text message ---
        if customer_number and mac_imessage:
            try:
                lines = [f"Thanks for calling Crewly! Here's your call recap:\n"]
                lines.append(f"📋 {call_summary}")
                lines.append(f"🏷️ Issue: {issue_type} | Sentiment: {sentiment}")
                if appointment_booked:
                    lines.append(f"✅ Appointment booked: {booking_date} at {booking_time}")
                else:
                    lines.append("📅 No appointment was booked on this call.")
                text_body = "\n".join(lines)

                print(f"🚀 Sending text to {customer_number}...")
                mac_imessage.send(
                    message=text_body,
                    phone_number=customer_number,
                    medium="iMessage"
                )
                print("✅ Text sent!")
            except Exception as e:
                print(f"❌ Failed to send text: {e}")
        elif not mac_imessage:
            print("⚠️ mac_imessage library not found. Cannot send text.")

        return {"status": "ok"}

    return {"status": "ignored"}

if __name__ == "__main__":
    print("🚀 Webhook Server running on port 8001...")
    uvicorn.run(app, host="0.0.0.0", port=8001)
```

### poke-mcp/api.py

```python
"""
REST API for the Crewly/poke-mcp frontend.
Run from the poke-mcp directory: uvicorn api:app --reload --port 8000
"""
import os
from pathlib import Path

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel

# Import after potentially changing cwd; run uvicorn from poke-mcp dir
from tools.price_calculator import (
    get_pricing_params,
    set_pricing_params,
    calculate_job_price,
)
from tools.inventory import (
    list_inventory,
    check_stock,
    update_stock,
    estimate_parts_needed,
)
from tools.schedule_clients import (
    list_clients,
    add_client as db_add_client,
    list_schedule,
    add_schedule_entry,
)
from agents.schedule_query import get_next_schedule_item_via_model
from tools.reach import (
    get_reach_settings,
    set_reach_settings as db_set_reach_settings,
    list_reach_markers,
    add_reach_marker as db_add_reach_marker,
    update_reach_marker as db_update_reach_marker,
    delete_reach_marker as db_delete_reach_marker,
)
from agents.negotiator import negotiate_job_offer

INDUSTRY = os.environ.get("INDUSTRY", "plumbing")

app = FastAPI(title="Crewly Job Tools API", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

frontend_path = Path(__file__).parent / "frontend"
if frontend_path.exists():
    app.mount("/assets", StaticFiles(directory=frontend_path / "assets"), name="assets")


# --- Request/Response models ---
class QuoteRequest(BaseModel):
    material_cost: float
    estimated_hours: float
    distance_miles: float = 0.0


class NegotiateRequest(BaseModel):
    offer_amount: float
    material_cost: float
    estimated_hours: float
    miles: float = 0.0


class PricingUpdate(BaseModel):
    hourly_rate: float | None = None
    margin: float | None = None
    gas_cost: float | None = None


class UpdateStockRequest(BaseModel):
    quantity_change: int


class EstimatePartsRequest(BaseModel):
    job_description: str


class ClientCreate(BaseModel):
    name: str
    phone: str = ""
    email: str = ""
    address: str = ""


class ScheduleCreate(BaseModel):
    client_id: int
    job_date: str
    job_time: str = ""
    description: str = ""
    status: str = "scheduled"


class ReachSettingsUpdate(BaseModel):
    center_lat: float | None = None
    center_lng: float | None = None
    max_radius_miles: float | None = None


class ReachMarkerCreate(BaseModel):
    lat: float
    lng: float
    label: str = ""


class ReachMarkerUpdate(BaseModel):
    lat: float | None = None
    lng: float | None = None
    label: str | None = None


# --- Routes ---
@app.get("/api/pricing")
def api_get_pricing():
    """Current pricing parameters (hourly rate, margin, gas per mile)."""
    return get_pricing_params()


@app.put("/api/pricing")
def api_set_pricing(body: PricingUpdate):
    """Update pricing parameters. Omitted fields are left unchanged."""
    set_pricing_params(
        hourly_rate=body.hourly_rate,
        margin=body.margin,
        gas_cost=body.gas_cost,
    )
    return {"ok": True, "pricing": get_pricing_params()}


@app.post("/api/quote")
def api_quote(body: QuoteRequest):
    """Minimum viable job price (floor) in USD."""
    price = calculate_job_price(
        material_cost=body.material_cost,
        estimated_hours=body.estimated_hours,
        distance_miles=body.distance_miles,
    )
    return {"minimum_price_usd": price, "unit": "USD"}


@app.post("/api/negotiate")
def api_negotiate(body: NegotiateRequest):
    """Evaluate customer offer vs minimum price; returns accept/negotiate/reject + response text."""
    try:
        result = negotiate_job_offer(
            offer_amount=body.offer_amount,
            material_cost=body.material_cost,
            estimated_hours=body.estimated_hours,
            miles=body.miles,
        )
        return {"ok": True, "response": result}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@app.get("/api/inventory")
def api_list_inventory():
    """List all inventory items with quantity and unit."""
    return list_inventory()


@app.get("/api/inventory/{item_name:path}")
def api_check_stock(item_name: str):
    """Check stock for an item (fuzzy match by name)."""
    return {"message": check_stock(item_name)}


@app.patch("/api/inventory/{item_name:path}")
def api_update_stock(item_name: str, body: UpdateStockRequest):
    """Update stock. quantity_change: negative to use, positive to restock."""
    return {"message": update_stock(item_name, body.quantity_change)}


@app.post("/api/estimate-parts")
def api_estimate_parts(body: EstimatePartsRequest):
    """Estimate parts needed for a job; returns use_from_inventory vs need_to_buy."""
    raw = estimate_parts_needed(body.job_description, industry=INDUSTRY)
    if isinstance(raw, str):
        import json
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return {"raw": raw}
    return raw


@app.get("/api/clients")
def api_list_clients():
    """List all clients."""
    return list_clients()


@app.post("/api/clients")
def api_add_client(body: ClientCreate):
    """Add a client."""
    return db_add_client(
        name=body.name,
        phone=body.phone,
        email=body.email,
        address=body.address,
    )


@app.get("/api/schedule/next")
def api_next_schedule():
    """Get the next upcoming schedule item by querying the poke model: 'what is the next schedule item for me'."""
    return get_next_schedule_item_via_model()


@app.get("/api/schedule")
def api_list_schedule(from_date: str | None = None, to_date: str | None = None):
    """List schedule entries. Optional query: from_date, to_date (ISO)."""
    return list_schedule(from_date=from_date, to_date=to_date)


@app.post("/api/schedule")
def api_add_schedule(body: Sche
[truncated — 2601 more characters]
```

### poke-mcp/agents/__init__.py

```python
try:
    from . import inventory_manager
except Exception:
    pass

```

### poke-mcp/tools/__init__.py

```python
# Import all tool modules so their @mcp.tool decorators run
from . import distance
from . import inventory
from . import price_calculator
from . import call_logs
```

[15 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]