├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .env.example ├── .gitignore ├── langgraph.json ├── package.json ├── src │ ├── index.ts │ ├── tools.ts │ └── types.ts └── tsconfig.json ├── frontend ├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app │ ├── MyRuntimeProvider.tsx │ ├── api │ │ └── [..._path] │ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components.json ├── components │ ├── tools │ │ ├── ToolFallback.tsx │ │ ├── price-snapshot │ │ │ ├── PriceSnapshotTool.tsx │ │ │ └── price-snapshot.tsx │ │ └── purchase-stock │ │ │ ├── PurchaseStockTool.tsx │ │ │ ├── transaction-confirmation-final.tsx │ │ │ └── transaction-confirmation-pending.tsx │ └── ui │ │ ├── button.tsx │ │ └── card.tsx ├── lib │ ├── chatApi.ts │ └── utils.ts ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── tailwind.config.ts └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules/ 3 | 4 | # misc 5 | .DS_Store 6 | *.pem 7 | 8 | # debug 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | ui-debug.log* 13 | 14 | # local env files 15 | .env*.local 16 | 17 | # typescript 18 | *.tsbuildinfo 19 | 20 | # vercel 21 | .vercel 22 | 23 | # testing 24 | coverage 25 | 26 | # next.js 27 | .next/ 28 | out/ 29 | 30 | # fumadocs 31 | .source 32 | 33 | # production 34 | build 35 | dist 36 | apps/docs/public/registry/ 37 | 38 | # turbo 39 | .turbo 40 | 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 AgentbaseAI Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stockbroker Human in the Loop 2 | 3 | The code for the Stockbroker Human in the Loop video can be found in this directory. It's setup as a monorepo-style project, with `frontend` and `backend` directories. 4 | The `frontend` directory contains a Next.js application which allows you to interact with the Stockbroker agent via a chat interface. 5 | The backend contains a LangGraph agent which powers the core functionality of the stockbroker. 6 | 7 | ## Deployment 8 | 9 | The stockbroker agent is publicly accessible through two interfaces: 10 | 11 | 1. API: 12 | 13 | > The Cloud API for the stockbroker agent is publicly accessible at the following base URL: `https://assistant-ui-stockbroker.vercel.app/api` 14 | 15 | 2. Web-based Chat Interface: 16 | > To go along with the API, we've also deployed this web-based chat interface for the stockbroker agent. 17 | > 18 | > You can access, and interact with it [here](https://assistant-ui-stockbroker.vercel.app). 19 | 20 | ## Setup 21 | 22 | To setup the stockbroker, install dependencies from the root of the monorepo: 23 | 24 | ```bash 25 | yarn install 26 | ``` 27 | 28 | This will install all dependencies required by both the frontend and backend projects. You can also run shared commands from the root of the project: 29 | 30 | ```bash 31 | yarn format 32 | 33 | yarn build 34 | ``` 35 | 36 | ## Environment variables 37 | 38 | ### Backend 39 | 40 | The backend requires Financial Datasets AI, Tavily and OpenAI API keys to run. Sign up here: 41 | 42 | - Financial Datasets AI: https://financialdatasets.ai/ 43 | - Tavily: https://tavily.com/ 44 | - OpenAI: https://platform.openai.com/signup 45 | 46 | Once you have your API keys, create a `.env` file in the [`./backend`](`./backend`) directory and add the following: 47 | 48 | ```bash 49 | FINANCIAL_DATASETS_API_KEY=YOUR_API_KEY 50 | TAVILY_API_KEY=YOUR_API_KEY 51 | OPENAI_API_KEY=YOUR_API_KEY 52 | ``` 53 | 54 | ### Frontend 55 | 56 | The frontend requires the production, or local deployment of your agent, along with a LangSmith API key (if calling the production endpoint), and finally the name of the agent to interact with (in this case `stockbroker`). 57 | 58 | For local development, you can find the API endpoint in the bottom left of LangGraph Studio, which defaults to `http://localhost:51497`. You can find the production URL in the deployment page of your LangGraph cloud deployment. 59 | 60 | Then, set the variables in a `.env` file inside [`./frontend`](./frontend): 61 | 62 | ```bash 63 | # Only required for production deployments 64 | # LANGCHAIN_API_KEY=YOUR_API_KEY 65 | LANGGRAPH_API_URL=https://assistant-ui-stockbroker.vercel.app/api # Or your production URL 66 | NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID=stockbroker 67 | ``` 68 | 69 | ## LangGraph Config 70 | 71 | The LangGraph configuration file for the stockbroker project is located inside [`./backend/langgraph.json`](./backend/langgraph.json). This file defines the stockbroker graph implemented in the project: `stockbroker`. 72 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # ------------------LangSmith tracing------------------ 2 | LANGCHAIN_API_KEY= 3 | LANGCHAIN_TRACING_V2=true 4 | LANGCHAIN_CALLBACKS_BACKGROUND=true 5 | # ----------------------------------------------------- 6 | 7 | FINANCIAL_DATASETS_API_KEY= 8 | TAVILY_API_KEY= 9 | OPENAI_API_KEY= 10 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | .turbo 4 | .env 5 | pdfs/** 6 | src/generated/** -------------------------------------------------------------------------------- /backend/langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "node_version": "20", 3 | "dockerfile_lines": [], 4 | "dependencies": ["."], 5 | "graphs": { 6 | "stockbroker": "./src/index.ts:graph" 7 | }, 8 | "env": ".env" 9 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "financial-expert", 3 | "description": "Financial expert LangGraph.js example", 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc --project tsconfig.json --outDir dist", 8 | "start": "tsx --experimental-wasm-modules -r dotenv/config src/index.ts", 9 | "format": "prettier --write \"**/*.ts\"", 10 | "format:check": "prettier --check \"**/*.ts\"" 11 | }, 12 | "author": "Brace Sproul", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@langchain/community": "^0.3.24", 16 | "@langchain/core": "^0.3.29", 17 | "@langchain/langgraph": "^0.2.39", 18 | "@langchain/openai": "^0.3.17", 19 | "zod": "^3.24.1" 20 | }, 21 | "devDependencies": { 22 | "@tsconfig/recommended": "^1.0.8", 23 | "dotenv": "^16.4.7", 24 | "prettier": "^3.4.2", 25 | "tsx": "^4.19.2", 26 | "typescript": "^5.7.3" 27 | } 28 | } -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ToolNode } from "@langchain/langgraph/prebuilt"; 2 | import { 3 | Annotation, 4 | END, 5 | START, 6 | StateGraph, 7 | NodeInterrupt, 8 | MessagesAnnotation, 9 | } from "@langchain/langgraph"; 10 | import { 11 | BaseMessage, 12 | ToolMessage, 13 | type AIMessage, 14 | } from "@langchain/core/messages"; 15 | import { ChatOpenAI } from "@langchain/openai"; 16 | import { 17 | priceSnapshotTool, 18 | StockPurchase, 19 | ALL_TOOLS_LIST, 20 | webSearchTool, 21 | } from "tools.js"; 22 | import { z } from "zod"; 23 | 24 | const GraphAnnotation = Annotation.Root({ 25 | ...MessagesAnnotation.spec, 26 | requestedStockPurchaseDetails: Annotation, 27 | }); 28 | 29 | const llm = new ChatOpenAI({ 30 | model: "gpt-4o", 31 | temperature: 0, 32 | }); 33 | 34 | const toolNode = new ToolNode(ALL_TOOLS_LIST); 35 | 36 | const callModel = async (state: typeof GraphAnnotation.State) => { 37 | const { messages } = state; 38 | 39 | const systemMessage = { 40 | role: "system", 41 | content: 42 | "You're an expert financial analyst, tasked with answering the users questions " + 43 | "about a given company or companies. You do not have up to date information on " + 44 | "the companies, so you much call tools when answering users questions. " + 45 | "All financial data tools require a company ticker to be passed in as a parameter. If you " + 46 | "do not know the ticker, you should use the web search tool to find it.", 47 | }; 48 | 49 | const llmWithTools = llm.bindTools(ALL_TOOLS_LIST); 50 | const result = await llmWithTools.invoke([systemMessage, ...messages]); 51 | return { messages: result }; 52 | }; 53 | 54 | const shouldContinue = (state: typeof GraphAnnotation.State) => { 55 | const { messages, requestedStockPurchaseDetails } = state; 56 | 57 | const lastMessage = messages[messages.length - 1]; 58 | 59 | // Cast here since `tool_calls` does not exist on `BaseMessage` 60 | const messageCastAI = lastMessage as AIMessage; 61 | if (messageCastAI._getType() !== "ai" || !messageCastAI.tool_calls?.length) { 62 | // LLM did not call any tools, or it's not an AI message, so we should end. 63 | return END; 64 | } 65 | 66 | // If `requestedStockPurchaseDetails` is present, we want to execute the purchase 67 | if (requestedStockPurchaseDetails) { 68 | return "execute_purchase"; 69 | } 70 | 71 | const { tool_calls } = messageCastAI; 72 | if (!tool_calls?.length) { 73 | throw new Error( 74 | "Expected tool_calls to be an array with at least one element" 75 | ); 76 | } 77 | 78 | return tool_calls.map((tc) => { 79 | if (tc.name === "purchase_stock") { 80 | // The user is trying to purchase a stock, route to the verify purchase node. 81 | return "prepare_purchase_details"; 82 | } else { 83 | return "tools"; 84 | } 85 | }); 86 | }; 87 | 88 | const findCompanyName = async (companyName: string) => { 89 | // Use the web search tool to find the ticker symbol for the company. 90 | const searchResults: string = await webSearchTool.invoke( 91 | `What is the ticker symbol for ${companyName}?` 92 | ); 93 | const llmWithTickerOutput = llm.withStructuredOutput( 94 | z 95 | .object({ 96 | ticker: z.string().describe("The ticker symbol of the company"), 97 | }) 98 | .describe( 99 | `Extract the ticker symbol of ${companyName} from the provided context.` 100 | ), 101 | { name: "extract_ticker" } 102 | ); 103 | const extractedTicker = await llmWithTickerOutput.invoke([ 104 | { 105 | role: "user", 106 | content: `Given the following search results, extract the ticker symbol for ${companyName}:\n${searchResults}`, 107 | }, 108 | ]); 109 | 110 | return extractedTicker.ticker; 111 | }; 112 | 113 | const preparePurchaseDetails = async (state: typeof GraphAnnotation.State) => { 114 | const { messages } = state; 115 | const lastMessage = messages[messages.length - 1]; 116 | if (lastMessage._getType() !== "ai") { 117 | throw new Error("Expected the last message to be an AI message"); 118 | } 119 | 120 | // Cast here since `tool_calls` does not exist on `BaseMessage` 121 | const messageCastAI = lastMessage as AIMessage; 122 | const purchaseStockTool = messageCastAI.tool_calls?.find( 123 | (tc) => tc.name === "purchase_stock" 124 | ); 125 | if (!purchaseStockTool) { 126 | throw new Error( 127 | "Expected the last AI message to have a purchase_stock tool call" 128 | ); 129 | } 130 | let { maxPurchasePrice, companyName, ticker } = purchaseStockTool.args; 131 | 132 | if (!ticker) { 133 | if (!companyName) { 134 | // The user did not provide the ticker or the company name. 135 | // Ask the user for the missing information. Also, if the 136 | // last message had a tool call we need to add a tool message 137 | // to the messages array. 138 | const toolMessages = messageCastAI.tool_calls?.map((tc) => { 139 | return { 140 | role: "tool", 141 | content: `Please provide the missing information for the ${tc.name} tool.`, 142 | id: tc.id, 143 | }; 144 | }); 145 | 146 | return { 147 | messages: [ 148 | ...(toolMessages ?? []), 149 | { 150 | role: "assistant", 151 | content: 152 | "Please provide either the company ticker or the company name to purchase stock.", 153 | }, 154 | ], 155 | }; 156 | } else { 157 | // The user did not provide the ticker, but did provide the company name. 158 | // Call the `findCompanyName` tool to get the ticker. 159 | ticker = await findCompanyName(purchaseStockTool.args.companyName); 160 | } 161 | } 162 | 163 | if (!maxPurchasePrice) { 164 | // If `maxPurchasePrice` is not defined, default to the current price. 165 | const priceSnapshot = await priceSnapshotTool.invoke({ ticker }); 166 | maxPurchasePrice = priceSnapshot.snapshot.price; 167 | } 168 | 169 | // Now we have the final ticker, we can return the purchase information. 170 | return { 171 | requestedStockPurchaseDetails: { 172 | ticker, 173 | quantity: purchaseStockTool.args.quantity ?? 1, // Default to one if not provided. 174 | maxPurchasePrice, 175 | }, 176 | }; 177 | }; 178 | 179 | const purchaseApproval = async (state: typeof GraphAnnotation.State) => { 180 | const { messages } = state; 181 | const lastMessage = messages[messages.length - 1]; 182 | if (!(lastMessage instanceof ToolMessage)) { 183 | // Interrupt the node to request permission to execute the purchase. 184 | throw new NodeInterrupt("Please confirm the purchase before executing."); 185 | } 186 | }; 187 | 188 | const shouldExecute = (state: typeof GraphAnnotation.State) => { 189 | const { messages } = state; 190 | const lastMessage = messages[messages.length - 1]; 191 | if (!(lastMessage instanceof ToolMessage)) { 192 | // Interrupt the node to request permission to execute the purchase. 193 | throw new NodeInterrupt("Please confirm the purchase before executing."); 194 | } 195 | 196 | const { approve } = JSON.parse(lastMessage.content as string); 197 | return approve ? "execute_purchase" : "agent"; 198 | }; 199 | 200 | const executePurchase = async (state: typeof GraphAnnotation.State) => { 201 | const { requestedStockPurchaseDetails } = state; 202 | if (!requestedStockPurchaseDetails) { 203 | throw new Error("Expected requestedStockPurchaseDetails to be present"); 204 | } 205 | 206 | // Execute the purchase. In this demo we'll just return a success message. 207 | const { ticker, quantity, maxPurchasePrice } = requestedStockPurchaseDetails; 208 | 209 | const toolCallId = "tool_" + Math.random().toString(36).substring(2); 210 | return { 211 | messages: [ 212 | { 213 | type: "ai", 214 | tool_calls: [ 215 | { 216 | name: "execute_purchase", 217 | id: toolCallId, 218 | args: { 219 | ticker, 220 | quantity, 221 | maxPurchasePrice, 222 | }, 223 | }, 224 | ], 225 | }, 226 | { 227 | type: "tool", 228 | name: "execute_purchase", 229 | tool_call_id: toolCallId, 230 | content: JSON.stringify({ 231 | success: true, 232 | }), 233 | }, 234 | { 235 | type: "ai", 236 | content: 237 | `Successfully purchased ${quantity} share(s) of ` + 238 | `${ticker} at $${maxPurchasePrice}/share.`, 239 | }, 240 | ], 241 | }; 242 | }; 243 | 244 | const workflow = new StateGraph(GraphAnnotation) 245 | .addNode("agent", callModel) 246 | .addEdge(START, "agent") 247 | .addNode("tools", toolNode) 248 | .addNode("prepare_purchase_details", preparePurchaseDetails) 249 | .addNode("purchase_approval", purchaseApproval) 250 | .addNode("execute_purchase", executePurchase) 251 | .addEdge("prepare_purchase_details", "purchase_approval") 252 | .addEdge("execute_purchase", END) 253 | .addEdge("tools", "agent") 254 | .addConditionalEdges("purchase_approval", shouldExecute, [ 255 | "agent", 256 | "execute_purchase", 257 | ]) 258 | .addConditionalEdges("agent", shouldContinue, [ 259 | "tools", 260 | END, 261 | "prepare_purchase_details", 262 | ]); 263 | 264 | export const graph = workflow.compile({ 265 | // The LangGraph Studio/Cloud API will automatically add a checkpointer 266 | // only uncomment if running locally 267 | // checkpointer: new MemorySaver(), 268 | }); 269 | -------------------------------------------------------------------------------- /backend/src/tools.ts: -------------------------------------------------------------------------------- 1 | import { TavilySearchResults } from "@langchain/community/tools/tavily_search"; 2 | import { tool } from "@langchain/core/tools"; 3 | import { 4 | IncomeStatementsResponse, 5 | BalanceSheetsResponse, 6 | CashFlowStatementsResponse, 7 | CompanyFactsResponse, 8 | SnapshotResponse, 9 | } from "types.js"; 10 | import { z } from "zod"; 11 | 12 | export async function callFinancialDatasetAPI< 13 | Output extends Record = Record 14 | >(fields: { 15 | endpoint: string; 16 | params: Record; 17 | }): Promise { 18 | if (!process.env.FINANCIAL_DATASETS_API_KEY) { 19 | throw new Error("FINANCIAL_DATASETS_API_KEY is not set"); 20 | } 21 | 22 | const baseURL = "https://api.financialdatasets.ai"; 23 | const queryParams = new URLSearchParams(fields.params).toString(); 24 | const url = `${baseURL}${fields.endpoint}?${queryParams}`; 25 | const response = await fetch(url, { 26 | method: "GET", 27 | headers: { 28 | "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY, 29 | }, 30 | }); 31 | 32 | if (!response.ok) { 33 | let res: string; 34 | try { 35 | res = JSON.stringify(await response.json(), null, 2); 36 | } catch (_) { 37 | try { 38 | res = await response.text(); 39 | } catch (_) { 40 | res = response.statusText; 41 | } 42 | } 43 | throw new Error( 44 | `Failed to fetch data from ${fields.endpoint}.\nResponse: ${res}` 45 | ); 46 | } 47 | const data = await response.json(); 48 | return data; 49 | } 50 | 51 | const incomeStatementsTool = tool( 52 | async (input) => { 53 | try { 54 | const data = await callFinancialDatasetAPI({ 55 | endpoint: "/financials/income-statements", 56 | params: { 57 | ticker: input.ticker, 58 | period: input.period ?? "annual", 59 | limit: input.limit.toString() ?? "5", 60 | }, 61 | }); 62 | return JSON.stringify(data, null); 63 | } catch (e: any) { 64 | console.warn("Error fetching income statements", e.message); 65 | return `An error occurred while fetching income statements: ${e.message}`; 66 | } 67 | }, 68 | { 69 | name: "income_statements", 70 | description: 71 | "Retrieves income statements for a specified company, showing detailed financial performance over a chosen time period. The output includes key metrics such as revenue, expenses, profits, and per-share data. Specifically, it provides: ticker, date, period type, revenue, cost of revenue, gross profit, operating expenses, income figures (operating, net, EBIT), tax expenses, earnings per share (basic and diluted), dividends per share, and share count information.", 72 | schema: z.object({ 73 | ticker: z.string().describe("The ticker of the stock. Example: 'AAPL'"), 74 | period: z 75 | .enum(["annual", "quarterly", "ttm"]) 76 | .describe("The time period of the income statement. Example: 'annual'") 77 | .optional() 78 | .default("annual"), 79 | limit: z 80 | .number() 81 | .int() 82 | .positive() 83 | .describe("The number of income statements to return. Example: 5") 84 | .optional() 85 | .default(5), 86 | }), 87 | } 88 | ); 89 | 90 | const balanceSheetsTool = tool( 91 | async (input) => { 92 | try { 93 | const data = await callFinancialDatasetAPI({ 94 | endpoint: "/financials/balance-sheets", 95 | params: { 96 | ticker: input.ticker, 97 | period: input.period ?? "annual", 98 | limit: input.limit.toString() ?? "5", 99 | }, 100 | }); 101 | return JSON.stringify(data, null); 102 | } catch (e: any) { 103 | console.warn("Error fetching balance sheets", e.message); 104 | return `An error occurred while fetching balance sheets: ${e.message}`; 105 | } 106 | }, 107 | { 108 | name: "balance_sheets", 109 | description: 110 | "Fetches balance sheets for a given company, providing a snapshot of its financial position at specific points in time. The output includes detailed information on assets (total, current, non-current), liabilities (total, current, non-current), and shareholders' equity. Specific data points include cash and equivalents, inventory, investments, property/plant/equipment, goodwill, debt, payables, retained earnings, and more. The result is a JSON stringified object containing an array of balance sheets.", 111 | schema: z.object({ 112 | ticker: z.string().describe("The ticker of the stock. Example: 'AAPL'"), 113 | period: z 114 | .enum(["annual", "quarterly", "ttm"]) 115 | .describe("The time period of the balance sheet. Example: 'annual'") 116 | .optional() 117 | .default("annual"), 118 | limit: z 119 | .number() 120 | .int() 121 | .positive() 122 | .describe("The number of balance sheets to return. Example: 5") 123 | .optional() 124 | .default(5), 125 | }), 126 | } 127 | ); 128 | 129 | const cashFlowStatementsTool = tool( 130 | async (input) => { 131 | try { 132 | const data = await callFinancialDatasetAPI({ 133 | endpoint: "/financials/cash-flow-statements", 134 | params: { 135 | ticker: input.ticker, 136 | period: input.period ?? "annual", 137 | limit: input.limit.toString() ?? "5", 138 | }, 139 | }); 140 | return JSON.stringify(data, null); 141 | } catch (e: any) { 142 | console.warn("Error fetching cash flow statements", e.message); 143 | return `An error occurred while fetching cash flow statements: ${e.message}`; 144 | } 145 | }, 146 | { 147 | name: "cash_flow_statements", 148 | description: 149 | "Obtains cash flow statements for a company, detailing the inflows and outflows of cash from operating, investing, and financing activities. The result is a JSON stringified object containing an array of cash flow statements. Each statement includes: ticker, date, report period, net cash flows from operations/investing/financing, depreciation and amortization, share-based compensation, capital expenditure, business and investment acquisitions/disposals, debt and equity issuances/repayments, dividends, change in cash and equivalents, and effect of exchange rate changes.", 150 | schema: z.object({ 151 | ticker: z.string().describe("The ticker of the stock. Example: 'AAPL'"), 152 | period: z 153 | .enum(["annual", "quarterly", "ttm"]) 154 | .describe("The period of the cash flow statement. Example: 'annual'") 155 | .optional() 156 | .default("annual"), 157 | limit: z 158 | .number() 159 | .int() 160 | .positive() 161 | .describe("The number of cash flow statements to return. Example: 5") 162 | .optional() 163 | .default(5), 164 | }), 165 | } 166 | ); 167 | 168 | const companyFactsTool = tool( 169 | async (input) => { 170 | try { 171 | const data = await callFinancialDatasetAPI({ 172 | endpoint: "/company/facts", 173 | params: { 174 | ticker: input.ticker, 175 | }, 176 | }); 177 | return JSON.stringify(data, null); 178 | } catch (e: any) { 179 | console.warn("Error fetching company facts", e.message); 180 | return `An error occurred while fetching company facts: ${e.message}`; 181 | } 182 | }, 183 | { 184 | name: "company_facts", 185 | description: 186 | "Provides key facts and information about a specified company. The result is a JSON stringified object containing details such as: ticker symbol, company name, CIK number, market capitalization, number of employees, SIC code and description, website URL, listing date, and whether the company is currently active.", 187 | schema: z.object({ 188 | ticker: z.string().describe("The ticker of the company. Example: 'AAPL'"), 189 | }), 190 | } 191 | ); 192 | 193 | export const priceSnapshotTool = tool( 194 | async (input) => { 195 | try { 196 | const data = await callFinancialDatasetAPI({ 197 | endpoint: "/prices/snapshot", 198 | params: { 199 | ticker: input.ticker, 200 | }, 201 | }); 202 | return JSON.stringify(data, null); 203 | } catch (e: any) { 204 | console.warn("Error fetching price snapshots", e.message); 205 | return `An error occurred while fetching price snapshots: ${e.message}`; 206 | } 207 | }, 208 | { 209 | name: "price_snapshot", 210 | description: 211 | "Retrieves the current stock price and related market data for a given company. The snapshot includes the current price, ticker symbol, day's change in price and percentage, timestamp of the data, and a nanosecond-precision timestamp. This tool should ALWAYS be called before purchasing a stock to ensure the most up-to-date price is used.", 212 | schema: z.object({ 213 | ticker: z.string().describe("The ticker of the company. Example: 'AAPL'"), 214 | }), 215 | } 216 | ); 217 | 218 | const stockPurchaseSchema = z.object({ 219 | ticker: z.string().describe("The ticker of the stock. Example: 'AAPL'"), 220 | quantity: z 221 | .number() 222 | .int() 223 | .positive() 224 | .describe("The quantity of stock to purchase."), 225 | maxPurchasePrice: z 226 | .number() 227 | .positive() 228 | .describe( 229 | "The max price at which to purchase the stock. Defaults to the current price." 230 | ), 231 | }); 232 | 233 | export type StockPurchase = z.infer; 234 | 235 | const purchaseStockTool = tool( 236 | (input) => { 237 | return ( 238 | `Please confirm that you want to purchase ${input.quantity} shares of ${input.ticker} at ` + 239 | `${ 240 | input.maxPurchasePrice 241 | ? `$${input.maxPurchasePrice} per share` 242 | : "the current price" 243 | }.` 244 | ); 245 | }, 246 | { 247 | name: "purchase_stock", 248 | description: 249 | "This tool should be called when a user wants to purchase a stock.", 250 | schema: z.object({ 251 | ticker: z 252 | .string() 253 | .optional() 254 | .describe("The ticker of the stock. Example: 'AAPL'"), 255 | companyName: z 256 | .string() 257 | .optional() 258 | .describe( 259 | "The name of the company. This field should be populated if you do not know the ticker." 260 | ), 261 | quantity: z 262 | .number() 263 | .int() 264 | .positive() 265 | .optional() 266 | .describe("The quantity of stock to purchase. Defaults to 1."), 267 | maxPurchasePrice: z 268 | .number() 269 | .positive() 270 | .optional() 271 | .describe( 272 | "The max price at which to purchase the stock. Defaults to the current price." 273 | ), 274 | }), 275 | } 276 | ); 277 | 278 | export const webSearchTool = new TavilySearchResults({ 279 | maxResults: 2, 280 | }); 281 | 282 | export const ALL_TOOLS_LIST = [ 283 | incomeStatementsTool, 284 | balanceSheetsTool, 285 | cashFlowStatementsTool, 286 | companyFactsTool, 287 | priceSnapshotTool, 288 | purchaseStockTool, 289 | webSearchTool, 290 | ]; 291 | 292 | export const SIMPLE_TOOLS_LIST = [ 293 | incomeStatementsTool, 294 | balanceSheetsTool, 295 | cashFlowStatementsTool, 296 | companyFactsTool, 297 | priceSnapshotTool, 298 | webSearchTool, 299 | ]; 300 | -------------------------------------------------------------------------------- /backend/src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ---------------------------------------------------- 3 | * ------------------ /company/facts ------------------ 4 | * ---------------------------------------------------- 5 | */ 6 | export interface CompanyFacts { 7 | ticker: string; 8 | name: string; 9 | cik: string; 10 | market_cap: number; 11 | number_of_employees: number; 12 | sic_code: string; 13 | sic_description: string; 14 | website_url: string; 15 | listing_date: string; 16 | is_active: boolean; 17 | } 18 | 19 | export interface CompanyFactsResponse { 20 | company_facts: CompanyFacts; 21 | } 22 | 23 | /** 24 | * ---------------------------------------------------- 25 | * ---------- /financials/income-statements ----------- 26 | * ---------------------------------------------------- 27 | */ 28 | export interface IncomeStatement { 29 | ticker: string; 30 | calendar_date: string; 31 | report_period: string; 32 | period: "quarterly" | "ttm" | "annual"; 33 | revenue: number; 34 | cost_of_revenue: number; 35 | gross_profit: number; 36 | operating_expense: number; 37 | selling_general_and_administrative_expenses: number; 38 | research_and_development: number; 39 | operating_income: number; 40 | interest_expense: number; 41 | ebit: number; 42 | income_tax_expense: number; 43 | net_income_discontinued_operations: number; 44 | net_income_non_controlling_interests: number; 45 | net_income: number; 46 | net_income_common_stock: number; 47 | preferred_dividends_impact: number; 48 | consolidated_income: number; 49 | earnings_per_share: number; 50 | earnings_per_share_diluted: number; 51 | dividends_per_common_share: number; 52 | weighted_average_shares: number; 53 | weighted_average_shares_diluted: number; 54 | } 55 | 56 | export interface IncomeStatementsResponse { 57 | income_statements: IncomeStatement[]; 58 | } 59 | 60 | /** 61 | * ---------------------------------------------------- 62 | * ------------ /financials/balance-sheets ------------ 63 | * ---------------------------------------------------- 64 | */ 65 | export interface BalanceSheet { 66 | ticker: string; 67 | calendar_date: string; 68 | report_period: string; 69 | period: "quarterly" | "ttm" | "annual"; 70 | total_assets: number; 71 | current_assets: number; 72 | cash_and_equivalents: number; 73 | inventory: number; 74 | current_investments: number; 75 | trade_and_non_trade_receivables: number; 76 | non_current_assets: number; 77 | property_plant_and_equipment: number; 78 | goodwill_and_intangible_assets: number; 79 | investments: number; 80 | non_current_investments: number; 81 | outstanding_shares: number; 82 | tax_assets: number; 83 | total_liabilities: number; 84 | current_liabilities: number; 85 | current_debt: number; 86 | trade_and_non_trade_payables: number; 87 | deferred_revenue: number; 88 | deposit_liabilities: number; 89 | non_current_liabilities: number; 90 | non_current_debt: number; 91 | tax_liabilities: number; 92 | shareholders_equity: number; 93 | retained_earnings: number; 94 | accumulated_other_comprehensive_income: number; 95 | total_debt: number; 96 | } 97 | 98 | export interface BalanceSheetsResponse { 99 | balance_sheets: BalanceSheet[]; 100 | } 101 | 102 | /** 103 | * ---------------------------------------------------- 104 | * --------- /financials/cash-flow-statements --------- 105 | * ---------------------------------------------------- 106 | */ 107 | export interface CashFlowStatement { 108 | ticker: string; 109 | calendar_date: string; 110 | report_period: string; 111 | period: "quarterly" | "ttm" | "annual"; 112 | net_cash_flow_from_operations: number; 113 | depreciation_and_amortization: number; 114 | share_based_compensation: number; 115 | net_cash_flow_from_investing: number; 116 | capital_expenditure: number; 117 | business_acquisitions_and_disposals: number; 118 | investment_acquisitions_and_disposals: number; 119 | net_cash_flow_from_financing: number; 120 | issuance_or_repayment_of_debt_securities: number; 121 | issuance_or_purchase_of_equity_shares: number; 122 | dividends_and_other_cash_distributions: number; 123 | change_in_cash_and_equivalents: number; 124 | effect_of_exchange_rate_changes: number; 125 | } 126 | 127 | export interface CashFlowStatementsResponse { 128 | cash_flow_statements: CashFlowStatement[]; 129 | } 130 | 131 | /** 132 | * ---------------------------------------------------- 133 | * --------- /prices/snapshot --------- 134 | * ---------------------------------------------------- 135 | */ 136 | export interface Snapshot { 137 | price: number; 138 | ticker: string; 139 | day_change: number; 140 | day_change_percent: number; 141 | time: string; 142 | time_nanoseconds: number; 143 | } 144 | 145 | export interface SnapshotResponse { 146 | snapshot: Snapshot; 147 | } 148 | 149 | /* ---------------------------------------------------- */ 150 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "lib": [ 6 | "ES2021", 7 | "ES2022.Object", 8 | "DOM" 9 | ], 10 | "target": "ES2021", 11 | "module": "nodenext", 12 | "sourceMap": true, 13 | "allowSyntheticDefaultImports": true, 14 | "baseUrl": "./src", 15 | "declaration": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUnusedParameters": true, 19 | "useDefineForClassFields": true, 20 | "strictPropertyInitialization": false 21 | }, 22 | "exclude": [ 23 | "node_modules/", 24 | "dist/", 25 | "tests/" 26 | ], 27 | "include": [ 28 | "./src" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=... 2 | NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID=stockbroker -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # LangGraph Example 2 | 3 | [Hosted Demo](https://assistant-ui-stockbroker.vercel.app/) 4 | 5 | This example demonstrates how to use LangChain LangGraph with assistant-ui. 6 | 7 | You need to set the following environment variables: 8 | 9 | ```bash 10 | # Only required for production deployments 11 | # LANGCHAIN_API_KEY=YOUR_API_KEY 12 | LANGGRAPH_API_URL=https://assistant-ui-stockbroker.vercel.app/api # Or your production URL 13 | NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID=stockbroker 14 | ``` 15 | 16 | To run the example, run the following commands: 17 | 18 | ```sh 19 | npm install 20 | npm run dev 21 | ``` -------------------------------------------------------------------------------- /frontend/app/MyRuntimeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import { AssistantRuntimeProvider } from "@assistant-ui/react"; 5 | import { useLangGraphRuntime } from "@assistant-ui/react-langgraph"; 6 | import { createThread, sendMessage } from "@/lib/chatApi"; 7 | 8 | export function MyRuntimeProvider({ 9 | children, 10 | }: Readonly<{ 11 | children: React.ReactNode; 12 | }>) { 13 | const threadIdRef = useRef(); 14 | const runtime = useLangGraphRuntime({ 15 | threadId: threadIdRef.current, 16 | stream: async (messages) => { 17 | if (!threadIdRef.current) { 18 | const { thread_id } = await createThread(); 19 | threadIdRef.current = thread_id; 20 | } 21 | const threadId = threadIdRef.current; 22 | return sendMessage({ 23 | threadId, 24 | messages, 25 | }); 26 | }, 27 | }); 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /frontend/app/api/[..._path]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | 3 | export const runtime = "edge"; 4 | 5 | function getCorsHeaders() { 6 | return { 7 | "Access-Control-Allow-Origin": "*", 8 | "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", 9 | "Access-Control-Allow-Headers": "*", 10 | }; 11 | } 12 | 13 | async function handleRequest(req: NextRequest, method: string) { 14 | try { 15 | const path = req.nextUrl.pathname.replace(/^\/?api\//, ""); 16 | const url = new URL(req.url); 17 | const searchParams = new URLSearchParams(url.search); 18 | searchParams.delete("_path"); 19 | searchParams.delete("nxtP_path"); 20 | const queryString = searchParams.toString() 21 | ? `?${searchParams.toString()}` 22 | : ""; 23 | 24 | const options: RequestInit = { 25 | method, 26 | headers: { 27 | "x-api-key": process.env["LANGCHAIN_API_KEY"] || "", 28 | }, 29 | }; 30 | 31 | if (["POST", "PUT", "PATCH"].includes(method)) { 32 | options.body = await req.text(); 33 | } 34 | 35 | const res = await fetch( 36 | `${process.env["LANGGRAPH_API_URL"]}/${path}${queryString}`, 37 | options, 38 | ); 39 | 40 | return new NextResponse(res.body, { 41 | status: res.status, 42 | statusText: res.statusText, 43 | headers: { 44 | ...res.headers, 45 | ...getCorsHeaders(), 46 | }, 47 | }); 48 | } catch (e: any) { 49 | return NextResponse.json({ error: e.message }, { status: e.status ?? 500 }); 50 | } 51 | } 52 | 53 | export const GET = (req: NextRequest) => handleRequest(req, "GET"); 54 | export const POST = (req: NextRequest) => handleRequest(req, "POST"); 55 | export const PUT = (req: NextRequest) => handleRequest(req, "PUT"); 56 | export const PATCH = (req: NextRequest) => handleRequest(req, "PATCH"); 57 | export const DELETE = (req: NextRequest) => handleRequest(req, "DELETE"); 58 | 59 | // Add a new OPTIONS handler 60 | export const OPTIONS = () => { 61 | return new NextResponse(null, { 62 | status: 204, 63 | headers: { 64 | ...getCorsHeaders(), 65 | }, 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 240 10% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 240 10% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 240 10% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 240 5.9% 10%; 50 | 51 | --secondary: 240 3.7% 15.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 240 3.7% 15.9%; 55 | --muted-foreground: 240 5% 64.9%; 56 | 57 | --accent: 240 3.7% 15.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Montserrat } from "next/font/google"; 5 | import { MyRuntimeProvider } from "./MyRuntimeProvider"; 6 | 7 | const montserrat = Montserrat({ subsets: ["latin"] }); 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: Readonly<{ 12 | children: React.ReactNode; 13 | }>) { 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Thread } from "@assistant-ui/react"; 4 | import { PriceSnapshotTool } from "@/components/tools/price-snapshot/PriceSnapshotTool"; 5 | import { PurchaseStockTool } from "@/components/tools/purchase-stock/PurchaseStockTool"; 6 | import { ToolFallback } from "@/components/tools/ToolFallback"; 7 | import { makeMarkdownText } from "@assistant-ui/react-markdown"; 8 | 9 | const MarkdownText = makeMarkdownText({}); 10 | 11 | export default function Home() { 12 | return ( 13 |
14 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/components/tools/ToolFallback.tsx: -------------------------------------------------------------------------------- 1 | import { ToolCallContentPartComponent } from "@assistant-ui/react"; 2 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; 3 | import { useState } from "react"; 4 | import { Button } from "../ui/button"; 5 | 6 | export const ToolFallback: ToolCallContentPartComponent = ({ 7 | toolName, 8 | argsText, 9 | result, 10 | }) => { 11 | const [isCollapsed, setIsCollapsed] = useState(true); 12 | return ( 13 |
14 |
15 | 16 |

17 | Used tool: {toolName} 18 |

19 |
20 | 23 |
24 | {!isCollapsed && ( 25 |
26 |
27 |
{argsText}
28 |
29 | {result !== undefined && ( 30 |
31 |

Result:

32 |
33 |                 {typeof result === "string"
34 |                   ? result
35 |                   : JSON.stringify(result, null, 2)}
36 |               
37 |
38 | )} 39 |
40 | )} 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/components/tools/price-snapshot/PriceSnapshotTool.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PriceSnapshot } from "./price-snapshot"; 4 | import { makeAssistantToolUI } from "@assistant-ui/react"; 5 | 6 | type PriceSnapshotToolArgs = { 7 | ticker: string; 8 | }; 9 | type PriceSnapshotToolResult = { 10 | snapshot: { 11 | price: number; 12 | day_change: number; 13 | day_change_percent: number; 14 | time: string; 15 | }; 16 | }; 17 | export const PriceSnapshotTool = makeAssistantToolUI< 18 | PriceSnapshotToolArgs, 19 | string 20 | >({ 21 | toolName: "price_snapshot", 22 | render: function PriceSnapshotUI({ args, result }) { 23 | let resultObj: PriceSnapshotToolResult | { error: string }; 24 | try { 25 | resultObj = result ? JSON.parse(result) : {}; 26 | } catch (e) { 27 | resultObj = { error: result! }; 28 | } 29 | 30 | return ( 31 |
32 |
33 |           price_snapshot({JSON.stringify(args)})
34 |         
35 | {"snapshot" in resultObj && ( 36 | 37 | )} 38 | {"error" in resultObj && ( 39 |

{resultObj.error}

40 | )} 41 |
42 | ); 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /frontend/components/tools/price-snapshot/price-snapshot.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; 4 | 5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 6 | 7 | type PriceSnapshotToolArgs = { 8 | ticker: string; 9 | }; 10 | 11 | type PriceSnapshotToolResult = { 12 | price: number; 13 | day_change: number; 14 | day_change_percent: number; 15 | time: string; 16 | }; 17 | 18 | export function PriceSnapshot({ 19 | ticker, 20 | price, 21 | day_change, 22 | day_change_percent, 23 | time, 24 | }: PriceSnapshotToolArgs & PriceSnapshotToolResult) { 25 | const isPositiveChange = day_change >= 0; 26 | const changeColor = isPositiveChange ? "text-green-600" : "text-red-600"; 27 | const ArrowIcon = isPositiveChange ? ArrowUpIcon : ArrowDownIcon; 28 | 29 | return ( 30 | 31 | 32 | {ticker} 33 | 34 | 35 |
36 |
37 |

${price?.toFixed(2)}

38 |
39 |
40 |

Day Change

41 |

44 | $ 45 | {Math.abs(day_change)?.toFixed(2)} ( 46 | {Math.abs(day_change_percent)?.toFixed(2)}%) 47 |

48 |
49 |
50 |

Last Updated

51 |

52 | {new Date(time).toLocaleTimeString()} 53 |

54 |
55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /frontend/components/tools/purchase-stock/PurchaseStockTool.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TransactionConfirmationPending } from "./transaction-confirmation-pending"; 4 | import { TransactionConfirmationFinal } from "./transaction-confirmation-final"; 5 | import { makeAssistantToolUI } from "@assistant-ui/react"; 6 | 7 | type PurchaseStockArgs = { 8 | ticker: string; 9 | companyName: string; 10 | quantity: number; 11 | maxPurchasePrice: number; 12 | }; 13 | 14 | type PurchaseStockResult = { 15 | approve?: boolean; 16 | cancelled?: boolean; 17 | error?: string; 18 | }; 19 | 20 | export const PurchaseStockTool = makeAssistantToolUI( 21 | { 22 | toolName: "purchase_stock", 23 | render: function PurchaseStockUI({ args, result, status, addResult }) { 24 | let resultObj: PurchaseStockResult; 25 | try { 26 | resultObj = result ? JSON.parse(result) : {}; 27 | } catch (e) { 28 | resultObj = { error: result! }; 29 | } 30 | 31 | const handleReject = () => { 32 | addResult({ cancelled: true }); 33 | }; 34 | 35 | const handleConfirm = async () => { 36 | addResult({ approve: true }); 37 | }; 38 | 39 | return ( 40 |
41 |
42 |
43 |               purchase_stock({JSON.stringify(args)})
44 |             
45 |
46 | {!result && status.type !== "running" && ( 47 | 52 | )} 53 | {resultObj.approve && } 54 | {resultObj.approve === false && ( 55 |
User rejected purchase
56 | )} 57 | {resultObj.cancelled && ( 58 |
Cancelled
59 | )} 60 |
61 | ); 62 | }, 63 | } 64 | ); 65 | -------------------------------------------------------------------------------- /frontend/components/tools/purchase-stock/transaction-confirmation-final.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CheckCircle } from "lucide-react"; 4 | 5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 6 | 7 | type TransactionConfirmation = { 8 | ticker: string; 9 | companyName: string; 10 | quantity: number; 11 | maxPurchasePrice: number; 12 | }; 13 | 14 | export function TransactionConfirmationFinal(props: TransactionConfirmation) { 15 | const { ticker, companyName, quantity, maxPurchasePrice } = props; 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | Transaction Confirmed 23 | 24 | 25 | 26 |
27 |

28 | Purchase Summary 29 |

30 |
31 |

Ticker:

32 |

{ticker}

33 |

Company:

34 |

{companyName}

35 |

Quantity:

36 |

{quantity} shares

37 |

Price per Share:

38 |

${maxPurchasePrice?.toFixed(2)}

39 |
40 |
41 |
42 |

Total Cost:

43 |

44 | ${(quantity * maxPurchasePrice)?.toFixed(2)} 45 |

46 |
47 |

48 | Your purchase of {quantity} shares of {companyName} ({ticker}) has 49 | been successfully processed. 50 |

51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /frontend/components/tools/purchase-stock/transaction-confirmation-pending.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CheckIcon, XIcon } from "lucide-react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardFooter, 10 | CardHeader, 11 | CardTitle, 12 | } from "@/components/ui/card"; 13 | 14 | type TransactionConfirmation = { 15 | ticker: string; 16 | companyName: string; 17 | quantity: number; 18 | maxPurchasePrice: number; 19 | onConfirm: () => void; 20 | onReject: () => void; 21 | }; 22 | 23 | export function TransactionConfirmationPending(props: TransactionConfirmation) { 24 | const { 25 | ticker, 26 | companyName, 27 | quantity, 28 | maxPurchasePrice, 29 | onConfirm, 30 | onReject, 31 | } = props; 32 | 33 | return ( 34 | 35 | 36 | 37 | Confirm Transaction 38 | 39 | 40 | 41 |
42 |

Ticker:

43 |

{ticker}

44 |

Company:

45 |

{companyName}

46 |

Quantity:

47 |

{quantity} shares

48 |

49 | Max Purchase Price: 50 |

51 |

${maxPurchasePrice?.toFixed(2)}

52 |
53 |
54 |

Total Maximum Cost:

55 |

56 | ${(quantity * maxPurchasePrice)?.toFixed(2)} 57 |

58 |
59 |
60 | 61 | 65 | 69 | 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /frontend/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /frontend/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /frontend/lib/chatApi.ts: -------------------------------------------------------------------------------- 1 | import { ThreadState, Client } from "@langchain/langgraph-sdk"; 2 | import { LangChainMessage } from "@assistant-ui/react-langgraph"; 3 | 4 | const createClient = () => { 5 | const apiUrl = 6 | process.env["NEXT_PUBLIC_LANGGRAPH_API_URL"] || 7 | new URL("/api", window.location.href).href; 8 | return new Client({ 9 | apiUrl, 10 | }); 11 | }; 12 | 13 | export const createAssistant = async (graphId: string) => { 14 | const client = createClient(); 15 | return client.assistants.create({ graphId }); 16 | }; 17 | 18 | export const createThread = async () => { 19 | const client = createClient(); 20 | return client.threads.create(); 21 | }; 22 | 23 | export const getThreadState = async ( 24 | threadId: string 25 | ): Promise>> => { 26 | const client = createClient(); 27 | return client.threads.getState(threadId); 28 | }; 29 | 30 | export const updateState = async ( 31 | threadId: string, 32 | fields: { 33 | newState: Record; 34 | asNode?: string; 35 | } 36 | ) => { 37 | const client = createClient(); 38 | return client.threads.updateState(threadId, { 39 | values: fields.newState, 40 | asNode: fields.asNode!, 41 | }); 42 | }; 43 | 44 | export const sendMessage = async (params: { 45 | threadId: string; 46 | messages: LangChainMessage[]; 47 | }) => { 48 | const client = createClient(); 49 | 50 | let input: Record | null = { 51 | messages: params.messages, 52 | }; 53 | const config = { 54 | configurable: { 55 | model_name: "openai", 56 | }, 57 | }; 58 | 59 | return client.runs.stream( 60 | params.threadId, 61 | process.env["NEXT_PUBLIC_LANGGRAPH_ASSISTANT_ID"]!, 62 | { 63 | input, 64 | config, 65 | streamMode: "messages", 66 | } 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /frontend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-langgraph", 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 | "@assistant-ui/react": "^0.7.35", 13 | "@assistant-ui/react-langgraph": "^0.1.16", 14 | "@assistant-ui/react-markdown": "^0.7.10", 15 | "@langchain/langgraph-sdk": "^0.0.36", 16 | "@radix-ui/react-slot": "^1.1.1", 17 | "class-variance-authority": "^0.7.1", 18 | "clsx": "^2.1.1", 19 | "js-cookie": "^3.0.5", 20 | "lucide-react": "^0.471.0", 21 | "next": "15.1.4", 22 | "react": "^19", 23 | "react-dom": "^19", 24 | "tailwind-merge": "^2.6.0", 25 | "tailwindcss-animate": "^1.0.7" 26 | }, 27 | "devDependencies": { 28 | "@types/js-cookie": "^3.0.6", 29 | "@types/node": "^22", 30 | "@types/react": "^19", 31 | "@types/react-dom": "^19", 32 | "eslint": "^9", 33 | "eslint-config-next": "15.1.4", 34 | "postcss": "^8", 35 | "tailwindcss": "^3.4.17", 36 | "typescript": "^5.7.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: { 16 | xs: "2rem", 17 | }, 18 | screens: { 19 | xs: "460px", 20 | }, 21 | }, 22 | extend: { 23 | colors: { 24 | border: "hsl(var(--border))", 25 | input: "hsl(var(--input))", 26 | ring: "hsl(var(--ring))", 27 | background: "hsl(var(--background))", 28 | foreground: "hsl(var(--foreground))", 29 | primary: { 30 | DEFAULT: "hsl(var(--primary))", 31 | foreground: "hsl(var(--primary-foreground))", 32 | }, 33 | secondary: { 34 | DEFAULT: "hsl(var(--secondary))", 35 | foreground: "hsl(var(--secondary-foreground))", 36 | }, 37 | destructive: { 38 | DEFAULT: "hsl(var(--destructive))", 39 | foreground: "hsl(var(--destructive-foreground))", 40 | }, 41 | muted: { 42 | DEFAULT: "hsl(var(--muted))", 43 | foreground: "hsl(var(--muted-foreground))", 44 | }, 45 | accent: { 46 | DEFAULT: "hsl(var(--accent))", 47 | foreground: "hsl(var(--accent-foreground))", 48 | }, 49 | popover: { 50 | DEFAULT: "hsl(var(--popover))", 51 | foreground: "hsl(var(--popover-foreground))", 52 | }, 53 | card: { 54 | DEFAULT: "hsl(var(--card))", 55 | foreground: "hsl(var(--card-foreground))", 56 | }, 57 | }, 58 | borderRadius: { 59 | lg: "var(--radius)", 60 | md: "calc(var(--radius) - 2px)", 61 | sm: "calc(var(--radius) - 4px)", 62 | }, 63 | keyframes: { 64 | "accordion-down": { 65 | from: { height: "0" }, 66 | to: { height: "var(--radix-accordion-content-height)" }, 67 | }, 68 | "accordion-up": { 69 | from: { height: "var(--radix-accordion-content-height)" }, 70 | to: { height: "0" }, 71 | }, 72 | }, 73 | animation: { 74 | "accordion-down": "accordion-down 0.2s ease-out", 75 | "accordion-up": "accordion-up 0.2s ease-out", 76 | }, 77 | }, 78 | }, 79 | plugins: [ 80 | require("tailwindcss-animate"), 81 | require("@assistant-ui/react/tailwindcss")({ shadcn: true }), 82 | require("@assistant-ui/react-markdown/tailwindcss"), 83 | ], 84 | } satisfies Config; 85 | 86 | export default config; 87 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "incremental": true, 5 | "plugins": [ 6 | { 7 | "name": "next" 8 | } 9 | ], 10 | "paths": { 11 | "@/*": [ 12 | "./*" 13 | ], 14 | "@assistant-ui/*": [ 15 | "../../packages/*/src" 16 | ], 17 | "@assistant-ui/react/*": [ 18 | "../../packages/react/src/*" 19 | ] 20 | }, 21 | "allowJs": true, 22 | "strictNullChecks": true, 23 | "jsx": "preserve", 24 | "lib": [ 25 | "dom", 26 | "dom.iterable", 27 | "esnext" 28 | ], 29 | "skipLibCheck": true, 30 | "strict": false, 31 | "noEmit": true, 32 | "module": "esnext", 33 | "esModuleInterop": true, 34 | "moduleResolution": "node", 35 | "resolveJsonModule": true, 36 | "isolatedModules": true 37 | }, 38 | "include": [ 39 | "next-env.d.ts", 40 | "**/*.ts", 41 | "**/*.tsx", 42 | ".next/types/**/*.ts" 43 | ], 44 | "exclude": [ 45 | "node_modules" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stockbroker-monorepo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "author": "Brace Sproul & Simon Farshid", 6 | "license": "MIT", 7 | "workspaces": [ 8 | "frontend", 9 | "backend" 10 | ], 11 | "scripts": { 12 | "build": "pnpm turbo run build", 13 | "lint": "pnpm turbo run lint", 14 | "format": "pnpm turbo run format" 15 | }, 16 | "devDependencies": { 17 | "turbo": "latest" 18 | }, 19 | "packageManager": "pnpm@9.15.3" 20 | } 21 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "backend" 3 | - "frontend" 4 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["**/dist/**"] 8 | }, 9 | "lint": { 10 | "dependsOn": ["^lint"] 11 | }, 12 | "format": { 13 | "dependsOn": ["^format"] 14 | } 15 | } 16 | } --------------------------------------------------------------------------------