{"id":21670,"date":"2026-08-14T13:04:43","date_gmt":"2026-08-14T11:04:43","guid":{"rendered":"https:\/\/www.juust.org\/?p=21670"},"modified":"2026-08-14T13:04:54","modified_gmt":"2026-08-14T11:04:54","slug":"your-own-ai-agent-in-typescript","status":"publish","type":"post","link":"https:\/\/www.juust.org\/index.php\/your-own-ai-agent-in-typescript\/2026\/08\/","title":{"rendered":"Your own AI Agent in TypeScript"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Build from Scratch \u2014 Types, Classes, and the Chat Completions API<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><em>If you can read a <code>while<\/code> loop, you can understand every line of an agent.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Frameworks like LangChain, CrewAI and AutoGen are great for shipping, but terrible for <em>learning<\/em>. When you write <code>Agent(executor=...).run()<\/code>, a black box makes a hundred design decisions for you \u2014 and hides every single one. The surprising truth? There is <strong>no deep machinery inside that box<\/strong>. The entire &#8220;agent&#8221; idea fits in a single <code>while<\/code> loop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide builds that loop from scratch in <strong>TypeScript<\/strong> \u2014 with real types, a clean class hierarchy, and the global <code>fetch<\/code> API. No SDKs, no agent frameworks, just the Chat Completions API and a handful of carefully typed functions. You will end up with a working agent that can calculate, read local files and search the web, and the TypeScript compiler will catch your mistakes before you ever hit the API.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything is on GitHub: <a href=\"https:\/\/github.com\/juustesout\/javascript-agents-example\" target=\"_blank\" rel=\"noopener\">github.com\/juustesout\/javascript-agents-example<\/a> (the TypeScript port lives in the same repo as the JS version).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">1. Installation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You need <strong>Node.js 18+<\/strong> (for the global <code>fetch<\/code>) and an OpenAI API key. Two dev dependencies:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>git clone https:\/\/github.com\/juustesout\/javascript-agents-example.git\ncd javascript-agents-example\nnpm install\ncp .env.example .env\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open <code>.env<\/code> and add your key:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>OPENAI_API_KEY=sk-proj-your-key-here\nOPENAI_MODEL=gpt-4o-mini\nAGENT_VERBOSE=1\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>.gitignore<\/code> already has <code>**\/.env<\/code>. The agent loads it automatically via its own built-in <code>loadEnv()<\/code> function. Now compile and run:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm run build        # tsc \u2014 compiles .ts to dist\/\nnpm run demo         # build + run the demo\nnpm run game         # build + run the negotiation game\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Only two dev dependencies: <code>typescript<\/code> and <code>@types\/node<\/code>. That is all the setup there is.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">2. Project Structure<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>node-agents-examples-ts\/\n\u251c\u2500\u2500 01_mini_agent\/\n\u2502   \u251c\u2500\u2500 agent.ts          # The core loop + 3 tools, fully typed\n\u2502   \u251c\u2500\u2500 demo.ts           # Entry point with example prompts\n\u2502   \u2514\u2500\u2500 sample.txt        # A test file for the file reader tool\n\u251c\u2500\u2500 02_agent_vs_agent\/\n\u2502   \u2514\u2500\u2500 game.ts           # Two agents negotiate a $100 split\n\u251c\u2500\u2500 dist\/                 # Compiled output (tsc writes here)\n\u251c\u2500\u2500 tsconfig.json         # ES2022, NodeNext module resolution\n\u2514\u2500\u2500 package.json\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Same structure as the JavaScript version, but every file is typed. The <code>tsconfig.json<\/code> targets ES2022 with NodeNext module resolution, so <code>import<\/code> statements compile to <code>.js<\/code> extensions that Node can run directly.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">3. The Core Loop \u2014 How Every Agent Works<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the whole trick in four steps:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The model receives the <strong>full conversation history<\/strong> plus a list of <strong>tool schemas<\/strong> (name, description, expected JSON arguments).<\/li>\n\n\n\n<li>The model replies with either <strong>plain text<\/strong> (it is ready to answer) or one or more <strong>tool calls<\/strong> (JSON like <code>{\"name\": \"calculator\", \"arguments\": {\"expression\": \"2+2\"}}<\/code>).<\/li>\n\n\n\n<li>If it is a tool call, <strong>you<\/strong> run the matching function and append the result as a <code>role=\"tool\"<\/code> message. Not a framework \u2014 your code.<\/li>\n\n\n\n<li>Repeat from step 1 until the model returns plain text. That text is the final answer.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The whole loop: <strong>model \u2192 tool \u2192 result \u2192 model \u2192 \u2026 \u2192 answer<\/strong>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">TypeScript types \u2014 the safety net<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The first thing you notice in the TypeScript version is the type definitions. Every message, every tool call, every response shape is explicitly typed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export type ToolCall = {\n  id: string;\n  type: \"function\";\n  function: {\n    name: string;\n    arguments: string;\n  };\n};\n\nexport type ChatMessage = {\n  role: \"system\" | \"user\" | \"assistant\" | \"tool\";\n  content: string | null;\n  tool_call_id?: string;\n  tool_calls?: ToolCall&#91;];\n};\n\nexport type ModelReply = {\n  content: string | null;\n  tool_calls?: ToolCall&#91;];\n};\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">These types are not decorative \u2014 they are the contract between your code and the OpenAI API. If you accidentally set <code>role: \"system\"<\/code> on a tool result, the TypeScript compiler flags it immediately. The <code>ModelReply<\/code> type tells you exactly what shape the API response should have, so you never have to guess.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The MiniAgent class<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>export class MiniAgent {\n  systemPrompt: string;\n  model: string;\n  temperature: number;\n  maxToolIters: number;\n  verbose: boolean;\n  baseUrl: string;\n  apiKey?: string;\n  tools: Record&lt;string, Tool&gt;;\n  messages: ChatMessage&#91;];\n\n  constructor(\n    systemPrompt: string,\n    {\n      model = null,\n      temperature = 0.7,\n      maxToolIters = DEFAULT_MAX_TOOL_ITERS,\n      verbose = DEFAULT_VERBOSE,\n      baseUrl = process.env.OPENAI_BASE_URL || \"https:\/\/api.openai.com\/v1\",\n      apiKey = process.env.OPENAI_API_KEY,\n    }: {\n      model?: string | null;\n      temperature?: number;\n      maxToolIters?: number;\n      verbose?: boolean;\n      baseUrl?: string;\n      apiKey?: string;\n    } = {},\n  ) {\n    this.systemPrompt = systemPrompt;\n    this.model = model || DEFAULT_MODEL;\n    this.temperature = temperature;\n    this.maxToolIters = maxToolIters;\n    this.verbose = verbose;\n    this.baseUrl = baseUrl.replace(\/\\\/+$\/, \"\");\n    this.apiKey = apiKey;\n    this.tools = {};\n    this.messages = &#91;];\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The constructor takes a destructured options object \u2014 every parameter is optional and has a default. The <code>messages<\/code> array is typed as <code>ChatMessage[]<\/code>, so adding a message with the wrong structure is a compile-time error, not a runtime surprise.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The <code>run()<\/code> method \u2014 the heart<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>async run(userPrompt: string, { reset = true }: { reset?: boolean } = {}): Promise&lt;string&gt; {\n  if (reset || this.messages.length === 0) {\n    this.messages = &#91;\n      { role: \"system\", content: this.systemPrompt },\n      { role: \"user\", content: userPrompt },\n    ];\n  } else {\n    this.messages.push({ role: \"user\", content: userPrompt });\n  }\n\n  for (let iteration = 1; iteration &lt;= this.maxToolIters; iteration++) {\n    this._log(`iteration ${iteration}: calling the model...`, _DIM);\n    const reply = await this._callModel();\n\n    if (reply.tool_calls &amp;&amp; reply.tool_calls.length &gt; 0) {\n      await this._handleToolCalls(reply.tool_calls);\n      continue;                   \/\/ loop back to the model\n    }\n\n    const answer = reply.content || \"\";\n    this.messages.push({ role: \"assistant\", content: answer });\n    this._log(\"final answer received.\", _DIM);\n    return answer;\n  }\n\n  throw new Error(`No answer after ${this.maxToolIters} iterations.`);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The TypeScript return type <code>Promise&lt;string&gt;<\/code> tells the caller exactly what to expect. No surprises.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How <code>_callModel()<\/code> works<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the only network call in the whole project. It converts the tool registry into OpenAI&#8217;s JSON schema format and sends the conversation alongside it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async _callModel(): Promise&lt;ModelReply&gt; {\n  const toolSchemas = Object.values(this.tools).map((tool) =&gt; tool.toOpenAISchema());\n\n  const response = await fetch(`${this.baseUrl}\/chat\/completions`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application\/json\",\n      Authorization: `Bearer ${this.apiKey}`,\n    },\n    body: JSON.stringify({\n      model: this.model,\n      messages: this.messages,\n      temperature: this.temperature,\n      tools: toolSchemas.length ? toolSchemas : undefined,\n      tool_choice: toolSchemas.length ? \"auto\" : undefined,\n    }),\n  });\n\n  if (!response.ok) {\n    const text = await response.text();\n    throw new Error(`OpenAI API error ${response.status}: ${text}`);\n  }\n\n  const data = await response.json();\n  return data.choices&#91;0].message as ModelReply;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The return type assertion <code>as ModelReply<\/code> tells the compiler what shape the API response has \u2014 the rest of the code is fully typed from this point on.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How tool results get back into context<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>async _handleToolCalls(toolCalls: ToolCall&#91;]): Promise&lt;void&gt; {\n  this.messages.push({\n    role: \"assistant\",\n    content: null,\n    tool_calls: toolCalls.map((call) =&gt; ({\n      id: call.id,\n      type: \"function\",\n      function: {\n        name: call.function.name,\n        arguments: call.function.arguments,\n      },\n    })),\n  });\n\n  for (const call of toolCalls) {\n    const result = await this._executeTool(call.function.name, call.function.arguments);\n    this.messages.push({ role: \"tool\", tool_call_id: call.id, content: result });\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every <code>tool_call_id<\/code> must pair back to the matching call. The TypeScript compiler enforces that <code>tool_call_id<\/code> is a string, preventing a whole class of bugs.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Executing a tool<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>async _executeTool(name: string, argumentsJson: string): Promise&lt;string&gt; {\n  const tool = this.tools&#91;name];\n  if (!tool) return `Error: unknown tool '${name}'.`;\n\n  try {\n    const args = JSON.parse(argumentsJson);\n    if (typeof args !== \"object\" || args === null || Array.isArray(args)) {\n      throw new TypeError(\"tool arguments must be a JSON object\");\n    }\n    this._log(`  -&gt; ${name}(${JSON.stringify(args)})`, _YELLOW);\n    result = String(await tool.func(args as Record&lt;string, unknown&gt;));\n  } catch (exc) {\n    result = `Error calling '${name}': ${exc instanceof Error ? exc.message : String(exc)}`;\n  }\n\n  this._log(`  &lt;- result: ${JSON.stringify(String(result).slice(0, 120))}`, _MAGENTA);\n  return String(result);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>as Record&lt;string, unknown&gt;<\/code> cast tells TypeScript that the parsed JSON object is safe to pass to the tool function \u2014 the actual validation happens at runtime in the function itself.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">4. Tools \u2014 Plain Functions, Typed Schemas<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A tool in this system is a class with a name, description, JSON Schema and a function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class Tool {\n  name: string;\n  description: string;\n  parameters: ToolParameters;\n  func: (args: Record&lt;string, unknown&gt;) =&gt; string | Promise&lt;string&gt;;\n\n  toOpenAISchema() {\n    return {\n      type: \"function\",\n      function: {\n        name: this.name,\n        description: this.description,\n        parameters: this.parameters,\n      },\n    };\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>ToolParameters<\/code> type is the JSON Schema shape:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export type ToolParameters = {\n  type: \"object\";\n  properties: Record&lt;string, Record&lt;string, unknown&gt;&gt;;\n  required?: string&#91;];\n};\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three tools ship with the agent, each registered via <code>addTool()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>addTool(\n  name: string,\n  description: string,\n  parameters: ToolParameters,\n  func: (args: Record&lt;string, unknown&gt;) =&gt; string | Promise&lt;string&gt;\n): this { ... }\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 1: Calculator \u2014 hand-written recursive descent parser<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Same parser as the JavaScript version: a recursive descent parser that only accepts arithmetic \u2014 <code>+ - * \/ \/\/ % **<\/code>, parentheses, and the constants <code>pi<\/code> and <code>e<\/code>. No <code>eval()<\/code>, no <code>new Function()<\/code>, no arbitrary code execution. Each grammar rule is a function:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export function calculator(expression: string): string {\n  try {\n    return String(_parseExpression(expression));\n  } catch (exc) {\n    return `Error: ${exc instanceof Error ? exc.message : String(exc)}`;\n  }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 2: Safe file reader (path traversal defence)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Uses <code>path.relative()<\/code> to block any attempt to escape the allowed directory:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export function read_local_file(filepath: string): string {\n  try {\n    const base = resolve(_DEFAULT_BASE_DIR);\n    const target = resolve(base, String(filepath));\n    const rel = relative(base, target);\n    if (rel.startsWith(\"..\") || isAbsolute(rel)) {\n      return `Error: '${filepath}' escapes the allowed directory. Path traversal blocked.`;\n    }\n    if (!existsSync(target)) return `Error: file not found.`;\n    const stat = statSync(target);\n    if (stat.size &gt; _MAX_FILE_BYTES) return `Error: file too large.`;\n    return readFileSync(target, \"utf8\").replace(\/\\s+$\/, \"\");\n  } catch (exc) {\n    return `Error reading file: ${exc instanceof Error ? exc.message : String(exc)}`;\n  }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Tool 3: Web search (zero API key, DuckDuckGo scraping)<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Scrapes the public DuckDuckGo HTML endpoint with <code>fetch<\/code> and regex \u2014 zero dependencies, zero API keys:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export async function web_search(query: string, max_results = 5): Promise&lt;string&gt; {\n  const res = await fetch(\n    `https:\/\/html.duckduckgo.com\/html\/?q=${encodeURIComponent(query)}`,\n    { headers: { \"User-Agent\": \"...\" },\n      signal: AbortSignal.timeout(15_000) }\n  );\n  const html = await res.text();\n  const anchors = &#91;...html.matchAll(\/class=\"result__a\" ... \/gs)];\n  const snippets = &#91;...html.matchAll(\/class=\"result__snippet\" ... \/gs)];\n  \/\/ Build formatted results\n  return lines.join(\"\\n\\n\");\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Registering tools<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Tools are registered via chained <code>addTool()<\/code> calls, wrapping each function to extract the named argument from the <code>args<\/code> object:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export function registerBuiltinTools(agent: MiniAgent): MiniAgent {\n  agent\n    .addTool(\"calculator\", \"Evaluate a math expression...\",\n      { type: \"object\", properties: {\n          expression: { type: \"string\", description: \"...\" },\n        }, required: &#91;\"expression\"] },\n      (args) =&gt; calculator(String(args.expression))\n    )\n    .addTool(\"read_local_file\", \"Read a text file...\",\n      { type: \"object\", properties: {\n          filepath: { type: \"string\", description: \"...\" },\n        }, required: &#91;\"filepath\"] },\n      (args) =&gt; read_local_file(String(args.filepath))\n    )\n    .addTool(\"web_search\", \"Search the web...\",\n      { type: \"object\", properties: {\n          query: { type: \"string\", description: \"...\" },\n          max_results: { type: \"integer\", description: \"...\" },\n        }, required: &#91;\"query\"] },\n      (args) =&gt; web_search(String(args.query), Number(args.max_results ?? 5))\n    );\n  return agent;\n}\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">5. Running the Agent<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>npm run build\nnode dist\/01_mini_agent\/demo.js\n\n# Or with a custom prompt:\nnode dist\/01_mini_agent\/demo.js \"What is the speed of light times 3600?\"\n\n# Or use the npm script shortcut:\nnpm run demo \"What is 2 ** 10 + 1000?\"\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What you see \u2014 the coloured trace of the loop:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>USER: What is 2 ** 10 + 1000? Use the calculator.\n\n&#91;agent] iteration 1: calling the model...\n&#91;agent]   -&gt; calculator({\"expression\":\"2 ** 10 + 1000\"})\n&#91;agent]   &lt;- result: \"2024\"\n&#91;agent] iteration 2: calling the model...\n&#91;agent] final answer received.\n\nFINAL ANSWER: The result of 2^10 + 1000 is 2024.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Multi-step (web search + calculation):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>USER: Search the web for the speed of light, then calculate\n       how far light travels in one hour.\n\n&#91;agent] iteration 1: calling the model...\n&#91;agent]   -&gt; web_search({\"query\":\"speed of light in vacuum\"})\n&#91;agent]   &lt;- result: \"299,792,458 m\/s\"\n&#91;agent] iteration 2: calling the model...\n&#91;agent]   -&gt; calculator({\"expression\":\"299792458 * 3600\"})\n&#91;agent]   &lt;- result: \"1079252848800\"\n&#91;agent] iteration 3: calling the model...\n&#91;agent] final answer received.\n\nFINAL ANSWER: Light travels ~1.08 trillion meters in one hour.\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">6. Two Agents, One Conversation \u2014 Multi-Agent Negotiation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two <code>MiniAgent<\/code> instances, one system prompt each, take turns proposing a split of $100. The only difference is the prompt \u2014 Alice is &#8220;The Rational Negotiator&#8221;, Bob is &#8220;The Bold Trader&#8221;:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const alice = new MiniAgent(systemPromptA, { temperature: 0.6, verbose: false });\nconst bob   = new MiniAgent(systemPromptB, { temperature: 0.6, verbose: false });\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The game loop parses each agent&#8217;s reply with a regex to extract the action:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const _ACTION_RE = \/(OFFER|ACCEPT|COUNTER|REJECT)\/i;\n\nfunction parseMove(text: string): { action: string; keep?: number } | null {\n  const match = _ACTION_RE.exec(text);\n  if (!match) return null;\n  const action = match&#91;1].toUpperCase();\n  if (action === \"ACCEPT\" || action === \"REJECT\") return { action };\n  const numbers = text.match(\/\\b(\\d{1,3})\\b\/g);\n  const keep = numbers ? parseInt(numbers&#91;0], 10) : null;\n  return { action, keep };\n}\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>npm run game\nnpm run game -- --quiet   # only parsed moves\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Example session:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Alice opens the negotiation...\nAlice -&gt; Alice keeps $50, gives $50\nBob   -&gt; Bob keeps $70, gives $30\nAlice -&gt; Alice keeps $40, gives $60\nBob   -&gt; Bob keeps $75, gives $25\nAlice rejects the deal.\n\nImpasse: no deal reached. Both agents walk away with $0.\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The lesson: multi-agent is not a framework feature. It is two single-agent loops passing messages through a shared state, with a text protocol and a parser that extracts structure.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">7. Putting It All Together<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the complete checklist to build your own agent from scratch in TypeScript:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Define your types<\/strong> \u2014 <code>ChatMessage<\/code>, <code>ToolCall<\/code>, <code>ModelReply<\/code>, <code>ToolParameters<\/code>. The compiler is your safety net.<\/li>\n\n\n\n<li><strong>Write a <code>Tool<\/code> class<\/strong> \u2014 name, description, JSON Schema, function. The schema tells the model what the tool does; the function is what actually runs.<\/li>\n\n\n\n<li><strong>Build the <code>MiniAgent<\/code> class<\/strong> \u2014 a message list (<code>ChatMessage[]<\/code>), a tool registry (<code>Record&lt;string, Tool><\/code>), and a <code>run()<\/code> method with the while-loop.<\/li>\n\n\n\n<li><strong>Implement <code>_callModel()<\/code><\/strong> \u2014 one <code>fetch()<\/code> call to <code>\/v1\/chat\/completions<\/code> with the typed message list and tool schemas.<\/li>\n\n\n\n<li><strong>Handle tool calls<\/strong> \u2014 append the assistant message first, then execute each tool and append the result with the matching <code>tool_call_id<\/code>.<\/li>\n\n\n\n<li><strong>Multi-agent<\/strong> \u2014 instantiate the same class with different system prompts. Define a protocol (OFFER\/ACCEPT\/COUNTER\/REJECT). Parse with regex. That is the whole pattern.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Frameworks add caching, streaming, middleware, GUIs and marketing \u2014 but underneath every single one is this same three-step loop, plain types, and a while-loop. Once you have written it by hand, every framework becomes obvious.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Going Further<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Swap the model<\/strong> \u2014 change <code>OPENAI_MODEL<\/code> or point <code>OPENAI_BASE_URL<\/code> at OpenRouter, Anthropic, or a local Ollama instance.<\/li>\n\n\n\n<li><strong>Add your own tools<\/strong> \u2014 write a function, write a JSON Schema, call <code>agent.addTool()<\/code>. The TypeScript types will catch mismatched arguments.<\/li>\n\n\n\n<li><strong>Stream the output<\/strong> \u2014 set <code>stream: true<\/code> in the API call and process chunks. The loop stays the same; only the I\/O changes.<\/li>\n\n\n\n<li><strong>Persist conversations<\/strong> \u2014 <code>JSON.stringify(agent.messages)<\/code> saves the full typed history. Reload with <code>JSON.parse()<\/code> and a type assertion.<\/li>\n\n\n\n<li><strong>Extend the negotiation<\/strong> \u2014 more players, different rules, a marketplace. The game logic is yours because you wrote it.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The full source is at <a href=\"https:\/\/github.com\/juustesout\/javascript-agents-example\" target=\"_blank\" rel=\"noopener\">github.com\/juustesout\/javascript-agents-example<\/a>. Clone it, open <code>agent.ts<\/code> and read the whole thing \u2014 you already know enough.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Build a basic AI Agent in TypeScript, a tutorial with a github repo<\/p>\n","protected":false},"author":5796,"featured_media":21671,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_sitemap_exclude":false,"_sitemap_priority":"","_sitemap_frequency":"","site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"set","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[543,484,479,305],"tags":[483],"class_list":["post-21670","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-agents","category-ai","category-javascript","category-programming","tag-ai"],"_links":{"self":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21670","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/users\/5796"}],"replies":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/comments?post=21670"}],"version-history":[{"count":1,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21670\/revisions"}],"predecessor-version":[{"id":21672,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/posts\/21670\/revisions\/21672"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/media\/21671"}],"wp:attachment":[{"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/media?parent=21670"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/categories?post=21670"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.juust.org\/index.php\/wp-json\/wp\/v2\/tags?post=21670"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}