diff --git a/templates/gtm/.agents/skills/.gitkeep b/templates/gtm/.agents/skills/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/templates/gtm/.env b/templates/gtm/.env new file mode 100644 index 00000000..eed836cf --- /dev/null +++ b/templates/gtm/.env @@ -0,0 +1,2 @@ +# https://www.builder.io/c/docs/using-your-api-key +VITE_PUBLIC_BUILDER_KEY=YJIGb4i01jvw0SRdL5Bt diff --git a/templates/gtm/AGENTS.md b/templates/gtm/AGENTS.md new file mode 100644 index 00000000..7550a5c0 --- /dev/null +++ b/templates/gtm/AGENTS.md @@ -0,0 +1,86 @@ +# GTM — Agent-Native App + +## Architecture + +This is an **@agent-native/core** application. A minimal GTM workspace where the agent is the operator and the human supervises. + +### Core Principles + +1. **Files as database** — All state in `data/`. No traditional DB. +2. **All AI through agent chat** — No inline LLM calls. UI delegates via `sendToAgentChat()`. +3. **Scripts for operations** — `pnpm script ` for complex work. +4. **SSE sync** — File watcher keeps UI in sync in real-time. +5. **Agent can update code** — Edit components, routes, scripts, skills. + +### Directory Structure + +``` +client/ # React SPA (supervision UI) +server/ # Express API (file serving + SSE) +scripts/ # Agent-callable scripts +shared/ # Shared types +data/ # File-based state (the agent's workspace) +.agents/skills/ # Agent-created skills +``` + +## First Interaction + +1. Read `data/context.md`. If empty or contains placeholder text, ask the human to describe their company, product, ICP, and value proposition. Help them write it. +2. Read `learnings.md` for any prior session memory. +3. Understand the request and do the work. + +## How You Work + +- **Think before you act.** Understand the request, then execute. +- **Write everything to `data/`.** This is your workspace. The UI renders it in real-time. +- **Use markdown** for research, narratives, and analysis. **Use JSON** for structured data. +- **Organize `data/` however makes sense.** Create directories as needed — `data/accounts/`, `data/emails/`, `data/research/` — whatever the work requires. +- **When you need a new capability**, write a script in `scripts/` and use it. + +## Self-Extension Tiers + +| Tier | Scope | Permission | +|------|-------|-----------| +| 1 | Data files (`data/`) | Write freely. This is your desk. | +| 2 | Scripts (`scripts/`) | Write when needed. Run `pnpm typecheck` after. | +| 3 | UI/app code (`client/`, `server/`) | Only with human approval. Explain changes. | +| 4 | Config, dependencies | Ask first. Never modify without permission. | + +## Available Scripts + +- `pnpm script web-search --query "search terms"` — Search the internet (requires SEARCH_API_KEY in .env) +- `pnpm script web-fetch --url "https://example.com" --output data/page.md` — Fetch a URL and convert to markdown + +## Starting Capabilities + +- **Research**: Companies, people, markets, news, competitors via web search and fetch +- **Writing**: Emails, summaries, analysis, reports — written to `data/` +- **Organizing**: Structuring information, building profiles, tracking work + +## Capabilities You Earn Over Time + +- **CRM access** — When the human provides API keys, write a HubSpot/Salesforce script +- **Email sending** — When trust is established, write an email sending script +- **Proactive monitoring** — When the human enables cron/heartbeat for background work +- **App modifications** — When the human asks for new UI views or dashboards + +## Data Model + +No pre-defined schema. You decide what structure fits the work. Examples of what you might create: + +- `data/accounts/{company-slug}.md` — Account research profiles +- `data/contacts/{name}.json` — Contact information +- `data/emails/{draft-name}.md` — Email drafts +- `data/research/{topic}.md` — Market or competitive research +- `data/campaigns/{campaign-name}/` — Campaign materials + +## Memory + +Write learnings, corrections, and user preferences to `learnings.md`. Read it at the start of every session. This is how you get better over time. + +## Key Patterns + +- API routes in `server/index.ts` serve files from `data/` +- UI delegates AI work via `sendToAgentChat()` +- Scripts write results to `data/` — SSE updates the UI automatically +- The UI adapts to whatever directory structure you create in `data/` diff --git a/templates/gtm/client/App.tsx b/templates/gtm/client/App.tsx new file mode 100644 index 00000000..3c1ff7ee --- /dev/null +++ b/templates/gtm/client/App.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useFileWatcher } from "@agent-native/core/client"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { Workspace } from "./pages/Workspace"; +import { Toaster } from "sonner"; + +const queryClient = new QueryClient(); + +function FileWatcher() { + useFileWatcher({ + queryClient, + queryKeys: ["files", "file"], + }); + return null; +} + +export default function App() { + return ( + + + + + + } /> + } /> + + + + ); +} diff --git a/templates/gtm/client/components/ActivityFeed.tsx b/templates/gtm/client/components/ActivityFeed.tsx new file mode 100644 index 00000000..854362fa --- /dev/null +++ b/templates/gtm/client/components/ActivityFeed.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { format } from "date-fns"; +import { Activity, FileText, FilePlus, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const MAX_EVENTS = 100; + +type FeedItem = { type: string; path: string; id: string; receivedAt: number }; + +function normalizeWorkspacePath(fsPath: string): string { + const n = fsPath.replace(/\\/g, "/"); + const dataIdx = n.lastIndexOf("/data/"); + if (dataIdx !== -1) return n.slice(dataIdx + "/data/".length); + return n; +} + +function eventMeta(type: string): { + icon: typeof FileText; + label: string; +} { + switch (type) { + case "add": + case "addDir": + return { icon: FilePlus, label: type === "addDir" ? "Folder added" : "Added" }; + case "unlink": + case "unlinkDir": + return { icon: Trash2, label: type === "unlinkDir" ? "Folder removed" : "Removed" }; + case "change": + return { icon: FileText, label: "Changed" }; + default: + return { icon: FileText, label: "Updated" }; + } +} + +export function ActivityFeed() { + const [events, setEvents] = useState([]); + const sourceRef = useRef(null); + + useEffect(() => { + const source = new EventSource("/api/events"); + sourceRef.current = source; + + source.onmessage = (e) => { + try { + const parsed = JSON.parse(e.data) as { path?: unknown; type?: unknown }; + if (typeof parsed.path !== "string" || typeof parsed.type !== "string") { + return; + } + const item: FeedItem = { + type: parsed.type, + path: parsed.path, + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + receivedAt: Date.now(), + }; + setEvents((prev) => [item, ...prev].slice(0, MAX_EVENTS)); + } catch { + /* ignore malformed */ + } + }; + + return () => { + source.close(); + sourceRef.current = null; + }; + }, []); + + return ( +
+
+ + Activity +
+
+ {events.length === 0 ? ( +

+ Waiting for agent activity... +

+ ) : ( +
    + {events.map((ev) => { + const rel = normalizeWorkspacePath(ev.path); + const { icon: Icon, label } = eventMeta(ev.type); + return ( +
  • + +
    + +
    +
    {label}
    +
    + {rel} +
    +
    + {format(ev.receivedAt, "HH:mm:ss")} +
    +
    +
    + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/templates/gtm/client/components/ContextEditor.tsx b/templates/gtm/client/components/ContextEditor.tsx new file mode 100644 index 00000000..7a64205c --- /dev/null +++ b/templates/gtm/client/components/ContextEditor.tsx @@ -0,0 +1,134 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { sendToAgentChat } from "@agent-native/core/client"; +import { Save, MessageSquare } from "lucide-react"; +import { toast } from "sonner"; +import type { FileContent } from "@shared/api"; +import { cn } from "@/lib/utils"; + +/** Matches starter `data/context.md` from the template (Task 11). */ +export const DEFAULT_CONTEXT_MD = `# GTM Context + +Replace this with your company's context. The agent reads this file to understand who you are, what you sell, and who you sell to. + +## Company + + +## Product + + +## ICP (Ideal Customer Profile) + + +## Value Proposition + + +## Competitors + +`; + +async function fetchContext(): Promise { + const res = await fetch("/api/files/context.md"); + if (!res.ok) { + return { path: "context.md", content: DEFAULT_CONTEXT_MD }; + } + return res.json() as Promise; +} + +export function ContextEditor() { + const queryClient = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ["file", "context.md"], + queryFn: fetchContext, + }); + + const [draft, setDraft] = useState(""); + + useEffect(() => { + if (data?.content !== undefined) { + setDraft(data.content); + } + }, [data?.content]); + + const normalizedDefault = useMemo(() => DEFAULT_CONTEXT_MD.trim(), []); + + const isNotDefault = draft.trim() !== normalizedDefault; + + const saveMutation = useMutation({ + mutationFn: async (content: string) => { + const res = await fetch("/api/files/context.md", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }); + if (!res.ok) throw new Error("Save failed"); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["file", "context.md"] }); + queryClient.invalidateQueries({ queryKey: ["files"] }); + toast.success("Context saved"); + }, + onError: () => { + toast.error("Could not save context"); + }, + }); + + if (isLoading && data === undefined) { + return ( +
Loading context…
+ ); + } + + return ( +
+
+

GTM context

+

+ The agent reads this file first. Fill in your company, ICP, and motion. +

+
+ +