├── .eslintrc.json ├── .gitignore ├── README.md ├── img └── docs-lead-example-review.jpg ├── next.config.mjs ├── package.json ├── src └── app │ └── api │ └── agent │ └── route.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | package-lock.json 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LangGraph | gotoHuman 2 | 3 | An AI agent [built with LangGraph](https://langchain-ai.github.io/langgraphjs/) integrating [gotoHuman](https://gotohuman.com) to keep a **human in the loop**. 4 | 5 | [gotoHuman](https://gotohuman.com) provides you with a central dashboard to review AI‑generated content, approve critical actions or provide input. And it seamlessly integrates with the AI stack of your choice. 6 | 7 | This example workflow uses our [Typescript SDK](https://github.com/gotohuman/gotohuman-js-sdk). 8 | 9 | It takes the email address of a new sales lead and drafts a personalized initial email outreach. The draft is reviewed and revised by a human before it gets sent out. 10 | 11 | ## Set it up 12 | 13 | ### Create a review form 14 | 15 | In gotoHuman, you can simply import the form template used here with the ID `OmmAnhbnWmird3oz60q2`. 16 | For the webhook, enter the URL where you deploy this app. It is called for each review response to resume your graph. 17 | 18 | Reviewers will find new pending reviews in their [gotoHuman inbox](https://app.gotohuman.com). You can also opt-in to receive a short-lived public link that you can freely send to reviewers. 19 | 20 | ### Deploy this agent 21 | 22 | Clone this Next.js repo, deploy it (e.g. to Vercel) and set up your environment variables 23 | 24 | ``` 25 | OPENAI_API_KEY = sk-proj-XXX 26 | GOTOHUMAN_API_KEY=XYZ 27 | GOTOHUMAN_FORM_ID=abcdef123 28 | POSTGRES_CONN_STRING="postgres://..." 29 | ``` 30 | 31 | ### Run it 32 | 33 | Trigger your agent whenever you get your hands on a new email address: 34 | 35 | #### Manually in gotoHuman 36 | Create a new trigger form with a text input field with ID `email` and include the same webhook as above. You'll see a trigger button appear in gotoHuman. 37 | 38 | #### Via API 39 | `HTTP POST [DEPLOY_URL]/api/agent` 40 | ```json 41 | { 42 | "email": "new.lead@email.com" 43 | } 44 | ``` 45 | 46 | Find a new request for review in your gotoHuman inbox as soon as the agent is done with its' research, drafted an outreach message and needs approval. 47 | 48 | ![gotoHuman - Human approval for AI lead outreach](./img/docs-lead-example-review.jpg) -------------------------------------------------------------------------------- /img/docs-lead-example-review.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gotohuman/gotohuman-langgraph-lead-example/7f5c4dc70b9c04ec8e1d9da715ad0a0f4100839b/img/docs-lead-example-review.jpg -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | async redirects() { 4 | return [ 5 | { 6 | source: '/', 7 | destination: '/api/agent', 8 | permanent: false, 9 | }, 10 | ] 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-gth-langgraph-lead", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@langchain/community": "^0.3.28", 13 | "@langchain/core": "^0.3.37", 14 | "@langchain/langgraph": "^0.2.44", 15 | "@langchain/langgraph-checkpoint-postgres": "^0.0.2", 16 | "@langchain/openai": "^0.3.17", 17 | "cheerio": "^1.0.0", 18 | "gotohuman": "~0.2.6", 19 | "next": "14.2.15", 20 | "react": "^18", 21 | "react-dom": "^18" 22 | }, 23 | "overrides": { 24 | "@langchain/core": "^0.3.37" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^20", 28 | "@types/react": "^18", 29 | "@types/react-dom": "^18", 30 | "eslint": "^8", 31 | "eslint-config-next": "14.2.15", 32 | "typescript": "^5" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/api/agent/route.ts: -------------------------------------------------------------------------------- 1 | export const maxDuration = 60; 2 | export const dynamic = 'force-dynamic'; 3 | 4 | import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio"; 5 | import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from "@langchain/core/messages"; 6 | import { tool } from "@langchain/core/tools"; 7 | import { z } from "zod"; 8 | import { ChatOpenAI } from "@langchain/openai"; 9 | import { StateGraph, START, END, Command, interrupt } from "@langchain/langgraph"; 10 | import { Annotation } from "@langchain/langgraph"; 11 | import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; 12 | import { ToolNode } from "@langchain/langgraph/prebuilt"; 13 | import { GotoHuman } from "gotohuman"; 14 | 15 | const gotoHuman = new GotoHuman() 16 | 17 | export async function POST(request: Request) { 18 | const req = await request.json() 19 | const threadId = req.meta?.threadId || (new Date()).getTime() // dummy random number 20 | console.log("POST received with meta ", req.meta) 21 | 22 | const StateAnnotation = Annotation.Root({ 23 | messages: Annotation({ 24 | reducer: (x, y) => x.concat(y), 25 | }), 26 | emailAddress: Annotation, 27 | leadWebsiteUrl: Annotation, 28 | emailToSend: Annotation, 29 | }) 30 | 31 | // Define the tools for the agent to use 32 | const webScrapeTool = tool(async ({ url }) => { 33 | const loader = new CheerioWebBaseLoader(url); 34 | const docs = await loader.load(); 35 | return docs.length ? (docs[0]?.pageContent || "") : ""; 36 | }, { 37 | name: "scraper", 38 | description: 39 | "Call to scrape a website.", 40 | schema: z.object({ 41 | url: z.string().describe("The website URL to scrape."), 42 | }), 43 | }); 44 | 45 | const summarizerTool = tool(async ({ content }) => { 46 | const messages = [ 47 | new SystemMessage("You are a helpful website content summarizer. You will be passed the content of a scraped company website. Please summarize it in 250-300 words focusing on what kind of company this is, the services they offer and how they operate."), 48 | new HumanMessage(content), 49 | ]; 50 | const model = new ChatOpenAI({ temperature: 0.5, model: "gpt-4o-mini" }) 51 | const response = await model.invoke(messages); 52 | return response.content 53 | }, { 54 | name: "summarizer", 55 | description: 56 | "Call to summarize scraped website content.", 57 | schema: z.object({ 58 | content: z.string().describe("The scraped website content that you want a summary of."), 59 | }), 60 | }); 61 | 62 | const draftTool = tool(async ({ emailAddress, companyDescription}) => { 63 | const noDomain = !(companyDescription||"").length 64 | 65 | const senderName = "Jess" 66 | const senderCompanyDesc = "FreshFruits is a premier subscription-based delivery service dedicated to filling company offices with a daily supply of fresh fruits and light, wholesome meals. Our mission is to enhance workplace wellness and productivity by providing nourishing, convenient food solutions that promote healthy eating habits. In addition to our daily deliveries, we offer exceptional catering services for business meetings, ensuring your team is fueled and focused for every important discussion. Committed to quality and freshness, FreshFruits sources only the finest ingredients from trusted local farmers and suppliers." 67 | 68 | const messages = [ 69 | new SystemMessage(`You are a helpful sales expert, great at writing enticing emails. 70 | You will write an email for ${senderName} who wants to reach out to a new prospect who left their email address: ${emailAddress} . ${senderName} works for the following company: 71 | ${senderCompanyDesc} 72 | Write no more than 300 words. 73 | ${!noDomain ? 'It must be tailored as much as possible to the prospect\'s company based on the website information we fetched. Don\'t mention that we got the information from the website. Include no placeholders! Your response should be nothing but the pure email body!' : ''}`), 74 | new HumanMessage((noDomain ? `No additional information found about the prospect` : `#Company website summary: 75 | ${companyDescription}`)), 76 | ]; 77 | const model = new ChatOpenAI({ temperature: 0.75, model: "gpt-4o-mini" }) 78 | const response = await model.invoke(messages); 79 | return response.content 80 | }, { 81 | name: "email-drafter", 82 | description: 83 | "Call to draft a sales email.", 84 | schema: z.object({ 85 | emailAddress: z.string().describe("The email address of the new lead that we want to reach out to."), 86 | companyDescription: z.string().describe("A description of the company based on the content found on its website."), 87 | }), 88 | }); 89 | 90 | const tools = [webScrapeTool, summarizerTool, draftTool]; 91 | const toolNode = new ToolNode(tools); 92 | 93 | const model = new ChatOpenAI({ temperature: 0.25, model: "gpt-4o-mini" }).bindTools(tools); 94 | 95 | // Define the function that determines whether to continue or not 96 | async function determineNextNode(state: typeof StateAnnotation.State): Promise<"tools" | "askHuman"> { 97 | const lastMessage = state.messages[state.messages.length - 1]; 98 | const castLastMessage = lastMessage as AIMessage; 99 | // If there are no tool calls, then we go to next node for human approval 100 | if (castLastMessage && !castLastMessage.tool_calls?.length) { 101 | return "askHuman"; 102 | } 103 | // Otherwise, we process the tool call 104 | return "tools"; 105 | } 106 | 107 | // Define the function that calls the model 108 | async function callModel(state: typeof StateAnnotation.State) { 109 | const messages = state.messages; 110 | console.log("callModel ", messages[messages.length - 1]) 111 | const response = await model.invoke(messages); 112 | 113 | // We return a list, because this will get added to the existing list 114 | return { messages: [response] }; 115 | } 116 | 117 | async function extractDomainNode(state: typeof StateAnnotation.State) { 118 | const url = extractDomain(state.emailAddress) 119 | console.log("extractDomainNode " + url) 120 | return { leadWebsiteUrl: url, messages: [new HumanMessage(`We got the email address of a new lead: ${state.emailAddress}. ${url ? (`Scrape the website of its' domain: ${url} . Then use the summarizer tool to describe it.`) : ""} Then write an outreach email.`)] } 121 | } 122 | 123 | function humanReviewNode(state: typeof StateAnnotation.State): Command { 124 | const emailDraft = state?.messages?.length ? state.messages[state.messages.length - 1].content : "" 125 | const result = interrupt({ 126 | emailDraft: emailDraft, 127 | }); 128 | const { response, reviewedEmail, comment } = result; 129 | 130 | if (response === "retry") { 131 | return new Command({ goto: "agent", update: { messages: [{role: "human", content: "Please regenerate the email draft and consider the following: " + comment}] } }); 132 | } else if (response === "approve") { 133 | return new Command({ goto: "sendEmail", update: { emailToSend: reviewedEmail } }); 134 | } 135 | return new Command({ goto: END }); 136 | } 137 | 138 | function sendEmailNode(state: typeof StateAnnotation.State) { 139 | console.log("sending email to " + state.emailAddress, state.emailToSend.slice(0,50)) 140 | // TODO: implement email sending. 141 | return {}; 142 | }; 143 | 144 | // Define a new graph 145 | const workflow = new StateGraph(StateAnnotation) 146 | .addNode("domainStep", extractDomainNode) 147 | .addNode("agent", callModel) 148 | .addNode("tools", toolNode) 149 | .addNode("askHuman", humanReviewNode, {ends:["agent", "sendEmail", END]}) 150 | .addNode("sendEmail", sendEmailNode) 151 | .addEdge(START, "domainStep") 152 | .addEdge("domainStep", "agent") 153 | .addConditionalEdges("agent", determineNextNode, ["askHuman", "tools"]) 154 | .addEdge("tools", "agent") 155 | .addEdge("sendEmail", END); 156 | 157 | // Initialize DB to persist state of the conversation thread between graph runs 158 | const checkpointer = PostgresSaver.fromConnString(process.env.POSTGRES_CONN_STRING!); 159 | if (!req.meta?.threadId) //if this is already a response from gotoHuman we don't need to setup again 160 | await checkpointer.setup(); 161 | 162 | const graph = workflow.compile({ checkpointer }); 163 | const config = { configurable: { thread_id: threadId } } 164 | 165 | if (req.type === 'review') { 166 | // we were called again with the review response from gotoHuman 167 | 168 | const approval = req.responseValues.emailApproval?.value 169 | const emailText = req.responseValues.emailDraft?.value; 170 | const retryComment = req.responseValues.retryComment?.value; 171 | console.log(`GTH approval ${approval} emailText ${emailText.slice(0,50)} retryComment ${retryComment}`) 172 | 173 | await graph.invoke(new Command({ resume: { response: approval, reviewedEmail: emailText, comment: retryComment } }), config); 174 | } else if (req.type === 'trigger' || req.email) { 175 | // we were called by the gotoHuman trigger or by another request including an email 176 | const email = req.email || req.responseValues?.email?.value || ""; 177 | 178 | const inputs = { emailAddress: email }; 179 | await graph.invoke(inputs, config); 180 | } 181 | 182 | const state = await graph.getState(config); 183 | if (state.next.length > 0 && state.next[0] === "askHuman") { 184 | const dataFromInterrupt = state.tasks?.[0]?.interrupts?.[0]?.value 185 | const reviewRequest = gotoHuman.createReview(process.env.GOTOHUMAN_FORM_ID) 186 | .addFieldData("email", state.values?.emailAddress || "") 187 | .addFieldData("emailDomain", {url: state.values?.leadWebsiteUrl || "", label: "Website checked"}) 188 | .addFieldData("emailDraft", dataFromInterrupt?.emailDraft) 189 | .addMetaData("threadId", threadId) 190 | // .assignToUsers(["jess@acme.org"]) 191 | const gotoHumanResponse = await reviewRequest.sendRequest() 192 | console.log("gotoHumanResponse", gotoHumanResponse) 193 | return Response.json({message: "The email draft needs human review.", link: gotoHumanResponse.gthLink}, {status: 200}) 194 | } 195 | console.log("graph ended") 196 | return Response.json({message: "Graph ended"}, {status: 200}) 197 | } 198 | 199 | function extractDomain(email: string) { 200 | const domain = email.split('@').pop(); 201 | if (!domain) return null; 202 | const regex = createDomainRegex(); 203 | return (!regex.test(domain)) ? `https://${domain}` : null 204 | } 205 | 206 | const commonProviders = [ 207 | 'gmail', 'yahoo', 'ymail', 'rocketmail', 208 | 'outlook', 'hotmail', 'live', 'msn', 209 | 'icloud', 'me', 'mac', 'aol', 210 | 'zoho', 'protonmail', 'mail', 'gmx' 211 | ]; 212 | 213 | function createDomainRegex() { 214 | // Escape any special regex characters in the domain names 215 | const escapedDomains = commonProviders.map(domain => domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); 216 | // Join the domains with the alternation operator (|) 217 | const pattern = `(^|\\.)(${escapedDomains.join('|')})(\\.|$)`; 218 | return new RegExp(pattern); 219 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "target": "ES2020", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------