|
| 1 | +/** |
| 2 | + * Full-fledged marketing agent example. |
| 3 | + * |
| 4 | + * Runs the marketing-agent GAP repo (github.com/shreyas-lyzr/marketing-agent) |
| 5 | + * as a multi-turn session: first turn sets product context, subsequent turns |
| 6 | + * request specific marketing deliverables. Each response streams in real-time |
| 7 | + * and the final outputs are saved to markdown files under ./marketing-outputs/. |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * ANTHROPIC_API_KEY=sk-ant-... bun run examples/marketing-agent.ts |
| 11 | + * |
| 12 | + * On subsequent runs, the session is resumed from disk so the agent remembers |
| 13 | + * the product context set in the first turn. |
| 14 | + * |
| 15 | + * ANTHROPIC_API_KEY=sk-ant-... bun run examples/marketing-agent.ts resume |
| 16 | + * |
| 17 | + * What this demonstrates: |
| 18 | + * - Running a real public GAP repo via ComputerAgent |
| 19 | + * - Streaming every sdk_message and tool call as they arrive |
| 20 | + * - Multi-turn conversation with a file-backed SessionStore |
| 21 | + * - Fetching agent-produced files via the Harness FS API |
| 22 | + * - clean dispose() via `await using` |
| 23 | + */ |
| 24 | + |
| 25 | +import { mkdir, writeFile, readFile } from "node:fs/promises"; |
| 26 | +import { existsSync } from "node:fs"; |
| 27 | +import { join } from "node:path"; |
| 28 | +import { ComputerAgent, LocalSubstrate } from "computeragent"; |
| 29 | + |
| 30 | +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; |
| 31 | +if (!ANTHROPIC_API_KEY) { |
| 32 | + console.error("Error: ANTHROPIC_API_KEY is not set."); |
| 33 | + console.error(" export ANTHROPIC_API_KEY=sk-ant-..."); |
| 34 | + process.exit(1); |
| 35 | +} |
| 36 | + |
| 37 | +const OUTPUTS_DIR = join(import.meta.dir ?? __dirname, "marketing-outputs"); |
| 38 | +const SESSION_FILE = join(OUTPUTS_DIR, ".session-id"); |
| 39 | +const RESUME = process.argv[2] === "resume"; |
| 40 | + |
| 41 | +// ── Helpers ────────────────────────────────────────────────────────────────── |
| 42 | + |
| 43 | +function hr(label: string): void { |
| 44 | + const line = "─".repeat(60); |
| 45 | + console.log(`\n${line}`); |
| 46 | + console.log(` ${label}`); |
| 47 | + console.log(`${line}`); |
| 48 | +} |
| 49 | + |
| 50 | +function dim(s: string): string { |
| 51 | + return `\x1b[2m${s}\x1b[0m`; |
| 52 | +} |
| 53 | + |
| 54 | +function green(s: string): string { |
| 55 | + return `\x1b[32m${s}\x1b[0m`; |
| 56 | +} |
| 57 | + |
| 58 | +function yellow(s: string): string { |
| 59 | + return `\x1b[33m${s}\x1b[0m`; |
| 60 | +} |
| 61 | + |
| 62 | +async function streamTurn( |
| 63 | + agent: ComputerAgent, |
| 64 | + message: string, |
| 65 | +): Promise<{ sessionId: string; result: string; harnessUrl: string }> { |
| 66 | + console.log(`\n${yellow("You:")} ${message}\n`); |
| 67 | + |
| 68 | + let result = ""; |
| 69 | + let sessionId = ""; |
| 70 | + |
| 71 | + const handle = agent.chat(message); |
| 72 | + |
| 73 | + for await (const ev of handle) { |
| 74 | + if (ev.kind === "ca_session_started") { |
| 75 | + sessionId = ev.sessionId; |
| 76 | + console.log(dim(`[session ${sessionId} • engine=${ev.engine}]`)); |
| 77 | + } else if (ev.kind === "sdk_message") { |
| 78 | + const p = ev.payload as Record<string, unknown>; |
| 79 | + |
| 80 | + if (p.type === "assistant") { |
| 81 | + const msg = p.message as { content?: unknown[] } | undefined; |
| 82 | + for (const block of msg?.content ?? []) { |
| 83 | + const b = block as { type?: string; text?: string; name?: string; input?: unknown }; |
| 84 | + if (b.type === "text" && b.text) { |
| 85 | + // Stream text progressively — print in chunks |
| 86 | + process.stdout.write(b.text); |
| 87 | + } else if (b.type === "tool_use") { |
| 88 | + const input = JSON.stringify(b.input ?? {}); |
| 89 | + console.log(`\n${dim(` → ${b.name}(${input.slice(0, 120)}${input.length > 120 ? "…" : ""})`)}`); |
| 90 | + } |
| 91 | + } |
| 92 | + } else if (p.type === "tool") { |
| 93 | + // tool result |
| 94 | + const content = typeof p.content === "string" ? p.content : JSON.stringify(p.content); |
| 95 | + console.log(dim(` ← ${content.slice(0, 200)}${content.length > 200 ? "…" : ""}`)); |
| 96 | + } else if (p.type === "result" && typeof p.result === "string") { |
| 97 | + result = p.result; |
| 98 | + } |
| 99 | + } else if (ev.kind === "ca_usage_snapshot") { |
| 100 | + const u = ev as unknown as { input_tokens?: number; output_tokens?: number; cost_usd?: number }; |
| 101 | + if (u.cost_usd !== undefined) { |
| 102 | + console.log(`\n${dim(`[usage: in=${u.input_tokens} out=${u.output_tokens} cost=$${u.cost_usd?.toFixed(4)}]`)}`); |
| 103 | + } |
| 104 | + } else if (ev.kind === "ca_session_ended") { |
| 105 | + console.log(`\n${dim(`[ended: ${ev.reason}]`)}`); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + const harnessUrl = await agent.harnessUrl(); |
| 110 | + return { sessionId, result, harnessUrl }; |
| 111 | +} |
| 112 | + |
| 113 | +async function saveOutput(filename: string, content: string): Promise<void> { |
| 114 | + const path = join(OUTPUTS_DIR, filename); |
| 115 | + await writeFile(path, content, "utf8"); |
| 116 | + console.log(`\n${green("✓")} Saved → ${path}`); |
| 117 | +} |
| 118 | + |
| 119 | +async function fetchAgentFile(harnessUrl: string, sessionId: string, path: string): Promise<string | null> { |
| 120 | + try { |
| 121 | + const res = await fetch(`${harnessUrl}/v1/sessions/${sessionId}/fs/file?path=${encodeURIComponent(path)}`); |
| 122 | + if (!res.ok) return null; |
| 123 | + return await res.text(); |
| 124 | + } catch { |
| 125 | + return null; |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +// ── Main ───────────────────────────────────────────────────────────────────── |
| 130 | + |
| 131 | +await mkdir(OUTPUTS_DIR, { recursive: true }); |
| 132 | + |
| 133 | +let priorSessionId: string | undefined; |
| 134 | +if (RESUME && existsSync(SESSION_FILE)) { |
| 135 | + priorSessionId = (await readFile(SESSION_FILE, "utf8")).trim(); |
| 136 | + console.log(`\nResuming session: ${priorSessionId}`); |
| 137 | +} |
| 138 | + |
| 139 | +// Boot the marketing agent from the public GitHub repo. |
| 140 | +// LocalSubstrate spawns the harness in a subprocess — no Docker required. |
| 141 | +await using agent = new ComputerAgent({ |
| 142 | + source: { |
| 143 | + type: "git", |
| 144 | + url: "github.com/shreyas-lyzr/marketing-agent", |
| 145 | + }, |
| 146 | + harness: "claude-agent-sdk", |
| 147 | + runtime: new LocalSubstrate(), |
| 148 | + envs: { ANTHROPIC_API_KEY }, |
| 149 | + options: { |
| 150 | + permissionMode: "bypassPermissions", |
| 151 | + settingSources: ["project"], |
| 152 | + }, |
| 153 | + sessionStore: { |
| 154 | + kind: "file", |
| 155 | + options: { root: OUTPUTS_DIR }, |
| 156 | + }, |
| 157 | + ...(priorSessionId ? { sessionId: priorSessionId } : {}), |
| 158 | +}); |
| 159 | + |
| 160 | +// ── Turn 1: Product context ─────────────────────────────────────────────────── |
| 161 | + |
| 162 | +if (!RESUME) { |
| 163 | + hr("Turn 1 — Product Context"); |
| 164 | + |
| 165 | + const { sessionId } = await streamTurn( |
| 166 | + agent, |
| 167 | + `Here is our product context. Please confirm you've loaded it and identify the top 3 |
| 168 | +marketing challenges you'd recommend we tackle first. |
| 169 | +
|
| 170 | +Product: Lyzr AI — an enterprise platform for building, deploying, and governing AI agents at scale. |
| 171 | +Audience: Mid-market and enterprise engineering and IT leaders (CTOs, VP Engineering, Head of AI). |
| 172 | +Stage: Series A, $8M ARR, 120 customers, growing 15% MoM. |
| 173 | +Key differentiators: On-prem / VPC deployment, SOC2 compliant, LLM-agnostic (OpenAI, Anthropic, Gemini, Mistral). |
| 174 | +Main competitors: LangChain, CrewAI, Vertex AI Agent Builder. |
| 175 | +Current channels: mostly outbound sales, some inbound content, limited PLG motion. |
| 176 | +Top conversion barrier: enterprises want a PoC before committing — long evaluation cycles (60-90 days).`, |
| 177 | + ); |
| 178 | + |
| 179 | + await writeFile(SESSION_FILE, sessionId, "utf8"); |
| 180 | + console.log(`\nSession ID saved → ${SESSION_FILE}`); |
| 181 | +} |
| 182 | + |
| 183 | +// ── Turn 2: Cold email sequence ─────────────────────────────────────────────── |
| 184 | + |
| 185 | +hr("Turn 2 — Cold Email Sequence"); |
| 186 | + |
| 187 | +const { result: emailResult } = await streamTurn( |
| 188 | + agent, |
| 189 | + `Write a 4-email cold outreach sequence targeting VP Engineering at Series B+ SaaS companies |
| 190 | +(500–2000 employees). The goal of the sequence is to book a 30-minute PoC scoping call. |
| 191 | +Angle: they are likely already using LangChain or CrewAI and hitting reliability or governance issues at scale. |
| 192 | +Format each email with: Subject line, Body, Send timing.`, |
| 193 | +); |
| 194 | + |
| 195 | +await saveOutput( |
| 196 | + "cold-email-sequence.md", |
| 197 | + `# Cold Email Sequence — VP Engineering @ Series B+ SaaS\n\nGenerated by marketing-agent via ComputerAgent\n\n---\n\n${emailResult}`, |
| 198 | +); |
| 199 | + |
| 200 | +// ── Turn 3: Pricing strategy ────────────────────────────────────────────────── |
| 201 | + |
| 202 | +hr("Turn 3 — Pricing Strategy"); |
| 203 | + |
| 204 | +const { result: pricingResult } = await streamTurn( |
| 205 | + agent, |
| 206 | + `Design a 3-tier pricing structure for Lyzr AI. We currently charge flat enterprise contracts |
| 207 | +($3k–$15k/month). We want to add a self-serve tier to capture PLG motion and reduce the |
| 208 | +60-90 day sales cycle for smaller deals. Include: |
| 209 | +- Tier names and positioning |
| 210 | +- Pricing metric recommendation (per-seat vs. per-agent vs. usage-based) |
| 211 | +- Feature gates between tiers |
| 212 | +- Recommended price points with reasoning |
| 213 | +- A/B test ideas for the pricing page`, |
| 214 | +); |
| 215 | + |
| 216 | +await saveOutput( |
| 217 | + "pricing-strategy.md", |
| 218 | + `# Pricing Strategy — Lyzr AI\n\nGenerated by marketing-agent via ComputerAgent\n\n---\n\n${pricingResult}`, |
| 219 | +); |
| 220 | + |
| 221 | +// ── Turn 4: Launch strategy for self-serve tier ─────────────────────────────── |
| 222 | + |
| 223 | +hr("Turn 4 — Self-Serve Launch Strategy"); |
| 224 | + |
| 225 | +const { result: launchResult, sessionId: finalSession, harnessUrl } = await streamTurn( |
| 226 | + agent, |
| 227 | + `Now create a 30-day launch plan for the new self-serve tier. |
| 228 | +We want 500 signups in the first 30 days. Channels available: Product Hunt, HN Show, |
| 229 | +our existing email list (4,200 subscribers), LinkedIn (8k followers), and developer communities. |
| 230 | +Include: Pre-launch checklist, launch day playbook, week-by-week activation plan, |
| 231 | +success metrics, and the top 3 risks with mitigations.`, |
| 232 | +); |
| 233 | + |
| 234 | +await saveOutput( |
| 235 | + "launch-strategy.md", |
| 236 | + `# Self-Serve Launch Strategy — 30-Day Plan\n\nGenerated by marketing-agent via ComputerAgent\n\n---\n\n${launchResult}`, |
| 237 | +); |
| 238 | + |
| 239 | +// ── Bonus: check if agent wrote any files to its workdir ───────────────────── |
| 240 | + |
| 241 | +hr("Harness Filesystem"); |
| 242 | + |
| 243 | +try { |
| 244 | + const treeRes = await fetch( |
| 245 | + `${harnessUrl}/v1/sessions/${finalSession}/fs/tree?depth=2`, |
| 246 | + ); |
| 247 | + if (treeRes.ok) { |
| 248 | + const tree = (await treeRes.json()) as { entries: { path: string; type: string; size: number }[] }; |
| 249 | + const interesting = tree.entries.filter((e) => e.path !== "/" && !e.path.startsWith("/.")); |
| 250 | + if (interesting.length > 0) { |
| 251 | + console.log("\nFiles the agent wrote to its workspace:"); |
| 252 | + for (const e of interesting) { |
| 253 | + console.log(` ${e.type.padEnd(4)} ${e.path.padEnd(50)} ${e.size}b`); |
| 254 | + if (e.type === "file") { |
| 255 | + const content = await fetchAgentFile(harnessUrl, finalSession, e.path); |
| 256 | + if (content) { |
| 257 | + const outName = e.path.replace(/^\//, "").replace(/\//g, "-"); |
| 258 | + await saveOutput(`agent-workdir-${outName}`, content); |
| 259 | + } |
| 260 | + } |
| 261 | + } |
| 262 | + } else { |
| 263 | + console.log(dim(" (agent workdir is empty — all output was inline text)")); |
| 264 | + } |
| 265 | + } |
| 266 | +} catch { |
| 267 | + // FS API optional — harness may not expose it in all configurations |
| 268 | +} |
| 269 | + |
| 270 | +// ── Summary ─────────────────────────────────────────────────────────────────── |
| 271 | + |
| 272 | +hr("Done"); |
| 273 | +console.log(`\nAll outputs saved to: ${OUTPUTS_DIR}/`); |
| 274 | +console.log(" cold-email-sequence.md — 4-email VP Engineering outreach sequence"); |
| 275 | +console.log(" pricing-strategy.md — 3-tier SaaS pricing with feature gates"); |
| 276 | +console.log(" launch-strategy.md — 30-day self-serve launch plan"); |
| 277 | +console.log(`\nSession ID: ${finalSession}`); |
| 278 | +console.log("Run with 'resume' to continue this session in a fresh process:"); |
| 279 | +console.log(` ANTHROPIC_API_KEY=sk-... bun run examples/marketing-agent.ts resume\n`); |
0 commit comments