Skip to content
Draft

test #94

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
2 changes: 2 additions & 0 deletions templates/gtm/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# https://www.builder.io/c/docs/using-your-api-key
VITE_PUBLIC_BUILDER_KEY=YJIGb4i01jvw0SRdL5Bt
86 changes: 86 additions & 0 deletions templates/gtm/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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 <name>` 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/`
30 changes: 30 additions & 0 deletions templates/gtm/client/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>
<Toaster />
<FileWatcher />
<BrowserRouter>
<Routes>
<Route path="/" element={<Workspace />} />
<Route path="/file/*" element={<Workspace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
114 changes: 114 additions & 0 deletions templates/gtm/client/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -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<FeedItem[]>([]);
const sourceRef = useRef<EventSource | null>(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 (
<div className="flex h-full min-h-0 flex-col bg-card/30">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Activity className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="text-sm font-medium text-foreground">Activity</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{events.length === 0 ? (
<p className="px-1 py-8 text-center text-sm text-muted-foreground">
Waiting for agent activity...
</p>
) : (
<ul className="space-y-2">
{events.map((ev) => {
const rel = normalizeWorkspacePath(ev.path);
const { icon: Icon, label } = eventMeta(ev.type);
return (
<li key={ev.id}>
<Link
to={`/file/${rel}`}
className={cn(
"block rounded-md border border-transparent px-2 py-2 text-left transition-colors",
"hover:border-border hover:bg-accent/30",
)}
>
<div className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-foreground">{label}</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{rel}
</div>
<div className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground/80">
{format(ev.receivedAt, "HH:mm:ss")}
</div>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
134 changes: 134 additions & 0 deletions templates/gtm/client/components/ContextEditor.tsx
Original file line number Diff line number Diff line change
@@ -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
<!-- What does your company do? -->

## Product
<!-- What's the product? What problem does it solve? -->

## ICP (Ideal Customer Profile)
<!-- Who do you sell to? Industry, company size, titles, geography? -->

## Value Proposition
<!-- Why should someone buy this? What's the before/after? -->

## Competitors
<!-- Who else is in this space? How are you different? -->
`;

async function fetchContext(): Promise<FileContent> {
const res = await fetch("/api/files/context.md");
if (!res.ok) {
return { path: "context.md", content: DEFAULT_CONTEXT_MD };
}
return res.json() as Promise<FileContent>;
}

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 (
<div className="p-8 text-sm text-muted-foreground">Loading context…</div>
);
}

return (
<div className="mx-auto max-w-3xl p-6">
<div className="mb-6">
<h1 className="text-xl font-semibold tracking-tight text-foreground">GTM context</h1>
<p className="mt-1 text-sm text-muted-foreground">
The agent reads this file first. Fill in your company, ICP, and motion.
</p>
</div>

<textarea
className={cn(
"min-h-[420px] w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm",
"text-foreground shadow-sm ring-offset-background placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck
aria-label="Context markdown"
/>

<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground shadow hover:opacity-90 disabled:opacity-50"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate(draft)}
>
<Save className="h-4 w-4" aria-hidden />
Save
</button>

{isNotDefault ? (
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-border bg-secondary px-3 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
onClick={() =>
sendToAgentChat({
message:
"Read my context file and suggest what to work on first. What accounts should we target?",
submit: true,
})
}
>
<MessageSquare className="h-4 w-4" aria-hidden />
Ask Agent to Start
</button>
) : null}
</div>
</div>
);
}
Loading
Loading