building out a multi agent architecture

published: 03/25/2026

19 min read

Zoom overlay

Table of contents

Open Table of contents

overview

Previously, we covered what is multi agent architecture and the different types of it.

In this article, we will implement the plan and execute pattern to better understand how it works.

demo

architecture

We’ll build on top of the existing research agent architecture from part three.

current architecture

005 Building out a multi agent architecture

updated architecture

005 Building out a multi agent architecture-1

In the new architecture, we’ll add the following:

build time

The source code is available at github: https://github.com/tanshunyuan/glob-guides/tree/main/bricklaying-about-agents/multi-agent

We won’t be going through how to hook up the client to the agent, but in the source code a client tui is implemented with react ink.

The TUI client was inspired from a post https://ivanleo.com/blog/migrating-to-react-ink by ivan leo!

agent overall structure

import {
  END,
  MemorySaver,
  START,
  StateGraph,
} from "@langchain/langgraph";

const workflow = new StateGraph(overallState)
  .addNode(CONVERSATION_NODE, conversationNode, {
    ends: [PLANNER_NODE, END],
  })
  .addNode(PLANNER_NODE, plannerNode)
  .addNode(HUMAN_APPOVAL_NODE, humanApprovalNode, {
    ends: [EXECUTOR_NODE, PLANNER_NODE],
  })
  .addNode(EXECUTOR_NODE, executorNode)
  .addNode(SUMMARISE_NODE, summariseNode)
  .addEdge(START, CONVERSATION_NODE)
  .addEdge(PLANNER_NODE, HUMAN_APPOVAL_NODE)
  .addEdge(EXECUTOR_NODE, SUMMARISE_NODE)
  .addEdge(SUMMARISE_NODE, END);


export const agent = workflow.compile({
  checkpointer: new MemorySaver(),
});

This is a codified version of the architecture we saw at the top. It shows what nodes we implemented and how they’re connected through the different edges.

Two nodes stand out:

  1. CONVERSATION_NODE
  2. HUMAN_APPROVAL_NODE

Both have an ends attribute in the third parameter. It tells LangGraph that for this particular node it can terminate in more than one place.

For example, the CONVERSATION_NODE can either head to the PLANNER_NODE to create a plan or the END node to continue a conversation with the user.

Refer to this link for more info: https://docs.langchain.com/oss/javascript/langgraph/use-graph-api#combine-control-flow-and-state-updates-with-command

agent State

We’ll create an agent state to keep track of what it has done and control the information being passed to the model. Making the agent stateful.

import { MessagesValue, StateSchema } from "@langchain/langgraph";
import { AIMessage } from "langchain";
import z from "zod";

const overallState = new StateSchema({
  /**@description collection of messages between the user and agent */
  messages: MessagesValue,
  objective: z.string(),
  /**@description plan consisting a list of tasks */
  plan: z.array(z.string()),
  completedTaskAndResult: z.record(
    z.string(),
    z.string()
  ),
  feedback: z.string().optional(),
  result: z.string(),
});
type OverallState = typeof overallState;

With both agent state and the architecture out of the way, we can focus on the nodes.

conversation node

import { ChatOpenAI } from "@langchain/openai";
import { Command, END, GraphNode } from "@langchain/langgraph";
import { AIMessage, SystemMessage } from "langchain";
import z from "zod";

import { env } from "../../env.js";

const CONVERSATION_NODE = "conversationNode";
const conversationNode: GraphNode<OverallState> = async (state, config) => {
  const schema = z.object({
    is_clear: z
      .boolean()
      .describe(
        "True only if the user's message contains a specific, actionable task. False if vague, incomplete, or ambiguous.",
      ),
    objective: z
      .string()
      .nullable()
      .describe(
        "A concise restatement of the user's goal. Only populated when is_clear is true. Null otherwise.",
      ),
    followup: z
      .string()
      .nullable()
      .describe(
        "A single clarifying question to resolve ambiguity. Only populated when is_clear is false. Null otherwise.",
      ),
  });

  const model = new ChatOpenAI({
    model: "gpt-4.1-mini",
    apiKey: env.OPENAI_API_KEY,
  }).withStructuredOutput(schema);

  const systemPrompt = new SystemMessage(`
    You are an intent classifier for an AI agent pipeline.

    Given the conversation history, determine if the user has expressed a clear,
    actionable objective.

    Rules:
    - is_clear = true ONLY if you can extract a specific, self-contained task
    - is_clear = false if the request is vague, incomplete, or requires assumptions
    - If is_clear = true: populate 'objective' with a concise restatement of the
      user's goal. Set  'followup' to null.
    - If is_clear = false: populate 'followup' with a single, specific clarifying
      question. Set 'objective' to null.

    Examples of CLEAR: "Summarize this PDF", "Write a SQL query to find top 10 customers"
    Examples of VAGUE: "Help me with my project", "Do something with this data"
  `);
  const response = await model.invoke([systemPrompt, ...state.messages]);

  if (!response.is_clear && response.followup) {
    return new Command({
      update: {
        messages: [new AIMessage(response.followup)],
      },
      goto: END,
    });
  } else {
    return new Command({
      update: {
        objective: response.objective!,
      },
      goto: PLANNER_NODE,
    });
  }
};

A schema is used to ensure a structured output from the model, we need it to reliably tell us:

By following up with the user, we prevent the agent from acting on vague request which can waste tokens downstream

Also, Command class is used to:

user request isn’t clear and requires a follow up

005 Building out a multi agent architecture-2

user request and does not require a follow up

005 Building out a multi agent architecture-3

005 Building out a multi agent architecture-4

planner node

import { ChatOpenAI } from "@langchain/openai";
import { Command, GraphNode } from "@langchain/langgraph";
import { HumanMessage, SystemMessage } from "langchain";
import z from "zod";

import { env } from "../../env.js";

const PLANNER_NODE = "plannerNode";
const plannerNode: GraphNode<OverallState> = async (state, config) => {
  const schema = z.object({
    plan: z.array(z.string()),
  });
  const model = new ChatOpenAI({
    model: "gpt-4.1-mini",
    apiKey: env.OPENAI_API_KEY,
  }).withStructuredOutput(schema);

  const systemPrompt = new SystemMessage(`
    You are planning the next step for the agent.

    Return a plan for the user's objective.
  `);

  const plannerRequest = !state.feedback
    ? new HumanMessage(`Objective: ${state.objective}`)
    : new HumanMessage(`
      Revise the plan based on the user's feedback.

      Objective:
      ${state.objective}

      Previous plan:
      ${state.plan.join("\n")}

      ${!state.feedback ? "" : `User feedback: ${state.feedback}`}
  `);

  const response = await model.invoke([
    systemPrompt,
    ...state.messages,
    plannerRequest,
  ]);

  return new Command({
    update: {
      plan: response.plan,
      feedback: undefined,
    },
    goto: HUMAN_APPOVAL_NODE,
  });
};

The planner node will generate a plan based off the objective from the CONVERSATION_NODE. Crucially, if there is a feedback from the HUMAN_APPROVAL_NODE, it’ll regenerate a plan based on the user feedback and pass it back to HUMAN_APPROVAL_NODE for the user to review. We pass in the previous plan as well to give the model a point of reference what it has generated and how it can modify the plan based on the feedback

generated plan

005 Building out a multi agent architecture-5

human approval node

import { Command, END, GraphNode, interrupt } from "@langchain/langgraph";

export type HumanApprovalResponse =
  | {
      type: "accept";
    }
  | {
      type: "cancel";
      feedback: string | undefined;
    };

export type HumanApprovalRequest = {
  name: string;
  description: string;
  content: string[];
  actions: HumanApprovalResponse[];
};
const HUMAN_APPOVAL_NODE = "humanApprovalNode";
const humanApprovalNode: GraphNode<OverallState> = async (
  state,
): Promise<Command<OverallState>> => {
  const interruptRequest: HumanApprovalRequest = {
    name: "Plan Review",
    description: "Review the plan suggested by the planner",
    content: state.plan,
    actions: [{ type: "accept" }, { type: "cancel", feedback: undefined }],
  };

  const response: HumanApprovalResponse = interrupt(interruptRequest);
  switch (response.type) {
    case "accept":
      return new Command({
        goto: EXECUTOR_NODE,
      });
    case "cancel":
      return new Command({
        goto: PLANNER_NODE,
        update: {
          feedback: response.feedback
            ? `The user rejected the plan. Feedback: ${response.feedback}`
            : "The user rejected the plan.",
        },
      });
  }
};

Before the agent starts to work on the task within the plan, it needs to seek the user input. This pattern is called Human In The Loop and we use interrupt class from LangGraph to surface the plan for review.

If the user accepts the plan, it’ll head to the EXECUTOR_NODE next.

Else it’ll go back to the PLANNER_NODE to come up with a new plan for the user to review.

rejecting the plan with feedback

before

005 Building out a multi agent architecture-6

revised plan after the feedback from user

005 Building out a multi agent architecture-7

rejecting the plan without feedback

before 005 Building out a multi agent architecture-8

revised plan after no feedback from user 005 Building out a multi agent architecture-9

accepting the plan

005 Building out a multi agent architecture-10

executor node

import { Command, GraphNode } from "@langchain/langgraph";
import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch";

const executorNode: GraphNode<OverallState> = async (state, config) => {
  const taskAndResult: Record<string, string> = {};
  for (const task of state.plan) {
    dispatchCustomEvent("task_start", { task });
    const response = await researcherAgent.invoke({
      task,
    });
    taskAndResult[task] = response.result.text
    dispatchCustomEvent("task_done", { task });
  }
  console.log(taskAndResult)
  return new Command({
    update: {
      completedTaskAndResult: taskAndResult,
    },
  });
};

This node is the magic sauce, based on the plan we generated we’ll loop through it sequentially and assign the researcher sub agent a task. It’ll find information based on the task and return the final result to the parent agent when its done.

Now the parent agent doesn’t need to care about what has happened in the researcher, it just needs the result of it, preventing context bloating on the parent agent.

The result of the researcher will be stored in the completedTaskAndResult agent memory, passing it to the SUMMARIZER_AGENT

import { ChatOpenAI } from "@langchain/openai";
import {
  Command,
  END,
  HumanMessage,
  MessagesValue,
  START,
  StateGraph,
  StateSchema,
  SystemMessage,
} from "@langchain/langgraph";
import { AIMessage } from "langchain";
import z from "zod";

import { env } from "../../env.js";

const researcherState = new StateSchema({
  messages: MessagesValue,
  task: z.string(),
  result: z.custom<AIMessage>((val) => val instanceof AIMessage),
});
/**@description researcher sub agent */
const researcherAgent = new StateGraph(researcherState)
  .addNode("researcherNode", async (state) => {
    const model = new ChatOpenAI({
      model: "gpt-4.1-mini",
      apiKey: env.OPENAI_API_KEY,
      streaming: true,
    });
    const RESEARCHER_SYSTEM_MESSAGE = new SystemMessage(`
      You are a research assistant that helps users find and synthesize information on any topic.

      When given a research question:
      1. Synthesize findings into a clear, concise response
      2. Include source links in your answer

      Guidelines:
      - Prioritize recent sources when timeliness matters
      - Present multiple perspectives for debated topics
      - Be transparent about conflicting information or gaps in available data
      - Keep responses focused on answering the specific question asked
      `);

    const response = await model.invoke([
      RESEARCHER_SYSTEM_MESSAGE,
      new HumanMessage(state.task),
    ]);

    return new Command({
      update: {
        result: response,
      },
    });
  })
  .addEdge(START, "researcherNode")
  .addEdge("researcherNode", END)
  .compile();

The researcher itself will have its own state so that anything that it does is contained within itself and only return the completed result back to the parent agent. I didn’t include any tool calling here, but you can plug in actual search tools like tavily or serp to perform the web search

working on the plan

005 Building out a multi agent architecture-11

005 Building out a multi agent architecture-12

a peek into completedTaskAndResult

This information is backfilled which is why it looks a bit different from the plan you’re seeing

{
  "Research and compile a concise summary of the history of memes, covering their origin, evolution, rise on the internet, and cultural impact into a single cohesive overview.": 
  
  "The concept of \"memes\" originated in 1976 with evolutionary biologist Richard Dawkins, who coined the term in his book *The Selfish Gene*. Dawkins described memes as units of cultural transmission or imitation, analogous to genes in biological evolution, spreading ideas, behaviors, or styles within a culture.\n\nMemes existed in oral and cultural traditions long before the internet, manifesting as catchphrases, fashion, or rituals. With the rise of the internet in the late 1990s and early 2000s, memes transformed into digital forms, often images, videos, or phrases that rapidly spread online. Early internet memes included phenomena like the \"Dancing Baby\" (1996) and \"All Your Base Are Belong To Us\" (early 2000s).\n\nThe evolution of internet memes accelerated with the growth of social media platforms such as 4chan, Reddit, Tumblr, and later Twitter and Instagram. These platforms enabled user-generated content, remix culture, and viral sharing mechanics, turning memes into a dominant form of online communication and humor. The accessibility of meme-making tools further democratized content creation.\n\nCulturally, memes have grown from niche internet jokes into powerful vectors of social commentary, political expression, and community building. They influence public opinion, marketing, and even political campaigns, serving both humorous and subversive roles. However, memes also raise concerns about misinformation and cultural appropriation.\n\nIn summary, memes originated as a concept describing cultural replication, evolved through traditional media, and found new life on the internet as rapid, viral forms of expression, profoundly shaping contemporary culture and communication.\n\n### Sources\n- Dawkins, R. (1976). *The Selfish Gene*. Oxford University Press.\n- Shifman, L. (2014). *Memes in Digital Culture*. MIT Press.\n- Milner, R. M. (2016). *The World Made Meme: Public Conversations and Participatory Media*. MIT Press.\n- Know Your Meme Database: https://knowyourmeme.com/memes/history-of-memes\n- The New York Times on meme culture: https://www.nytimes.com/2019/07/15/style/memes.html"
}

summarise node

import { ChatOpenAI } from "@langchain/openai";
import { Command, GraphNode } from "@langchain/langgraph";
import { HumanMessage, SystemMessage } from "langchain";

const SUMMARISE_NODE = "summariseNode";
const summariseNode: GraphNode<OverallState> = async (state, config) => {
  const allResults = Object.values(state.completedTaskAndResult);
  const model = new ChatOpenAI({
    model: "gpt-4.1-mini",
    apiKey: env.OPENAI_API_KEY,
    streaming: true,
  });
  const systemPrompt = new SystemMessage(`
    you are a synthesizer, you take in all this information and respond with the final thing
    `);
  const response = await model.invoke([
    systemPrompt,
    new HumanMessage(allResults.join("\n")),
  ]);

  return new Command({
    update: {
      result: response.text,
    },
  });
};

This is the final step of the whole process, after we get the information from state.completedTaskAndResult it can be disjointed and can contain some redundant data. So we pass these information through another llm to make sure the output is coherent

the final result

Memes originated as a scientific concept introduced by Richard Dawkins in his 1976 book
      *The Selfish Gene*, where he coined the term "meme" to describe units of cultural
     transmission—ideas, behaviors, styles, or practices that propagate through imitation
     much like genes transmit biological information. Dawkins proposed memes as replicators
     capable of variation and selection, providing a framework for understanding cultural
     evolution through an evolutionary biology lens.

     Since then, memes have evolved far beyond this original definition, especially with the
      rise of the internet and social media. Early internet memes—such as the Dancing Baby
     (1996), All Your Base Are Belong To Us (early 2000s), Hamster Dance, LOLCats,
     Rickrolling, and Doge—illustrated how humorous or catchy images, phrases, and videos
     could spread rapidly and widely online. These viral phenomena laid the groundwork for
     meme culture as participatory, remixable, and highly adaptable communication.

     In contemporary digital culture, memes function as a dynamic form of modern folklore
     and social currency. They serve multiple purposes:

     - **Cultural expression:** Memes encapsulate ideas, emotions, humor, and social
     commentary in simple, relatable formats that resonate across diverse demographics and
     transcend language barriers.
     - **Social interaction:** Platforms like Twitter, Instagram, Reddit, TikTok, and others
      accelerate meme dissemination, enabling users to create, modify, and share content
     collaboratively, fostering community and shaping social discourse.
     - **Influence:** Memes impact public opinion, political activism, marketing, and
     identity formation, illustrating how cultural narratives evolve in decentralized,
     digital environments.

     Though memes promote creativity and democratize cultural participation, they also raise
      concerns around misinformation, intellectual property, cultural appropriation, and
     representation.

     **In summary:**

     - **Origin:** Richard Dawkins framed memes as units of cultural evolution analogous to
     genes.
     - **Early internet memes:** Viral artifacts like Dancing Baby and LOLCats showed how
     digital culture adopted and transformed the concept.
     - **Modern memes:** Rapidly evolving, participatory, and influential elements of
     digital communication shaping culture, society, and online interaction.

     **Key references for deeper understanding:**

     - Dawkins, R. (1976). *The Selfish Gene*.
     - Shifman, L. (2014). *Memes in Digital Culture*. MIT Press.
     - Milner, R. M. (2016). *The World Made Meme: Public Conversations and Participatory
     Media*. MIT Press.
     - Phillips, W. (2015). *This Is Why We Can’t Have Nice Things: Mapping the Relationship
      Between Online Trolling and Mainstream Culture*. MIT Press.
     - Know Your Meme: https://knowyourmeme.com/
     - Stanford Encyclopedia of Philosophy, Memetics entry:
     https://plato.stanford.edu/entries/memetics/
     - The Guardian, How Internet Memes Have Revolutionised Culture: https://www.theguardian
     .com/technology/2018/oct/01/how-internet-memes-have-revolutionised-culture

     Memes exemplify how cultural transmission has adapted and accelerated through digital
     technologies, serving as powerful, evolving artifacts of human creativity and social
     connection.

the whole code

import { ChatOpenAI } from "@langchain/openai";
import { env } from "../../env.js";
import {
  Command,
  END,
  GraphNode,
  interrupt,
  MemorySaver,
  MessagesValue,
  START,
  StateGraph,
  StateSchema,
} from "@langchain/langgraph";
import { SystemMessage, HumanMessage, AIMessage } from "langchain";
import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch";
import z from "zod";

const overallState = new StateSchema({
  /**@description collection of messages between the user and agent */
  messages: MessagesValue,
  objective: z.string(),
  /**@description plan consisting a list of tasks */
  plan: z.array(z.string()),
  completedTaskAndResult: z.record(z.string(), z.string()),
  feedback: z.string().optional(),
  result: z.string(),
});
type OverallState = typeof overallState;

const CONVERSATION_NODE = "conversationNode";
const conversationNode: GraphNode<OverallState> = async (state, config) => {
  const schema = z.object({
    is_clear: z
      .boolean()
      .describe(
        "True only if the user's message contains a specific, actionable task. False if vague, incomplete, or ambiguous.",
      ),
    objective: z
      .string()
      .nullable()
      .describe(
        "A concise restatement of the user's goal. Only populated when is_clear is true. Null otherwise.",
      ),
    followup: z
      .string()
      .nullable()
      .describe(
        "A single clarifying question to resolve ambiguity. Only populated when is_clear is false. Null otherwise.",
      ),
  });

  const model = new ChatOpenAI({
    model: "gpt-4.1-mini",
    apiKey: env.OPENAI_API_KEY,
  }).withStructuredOutput(schema);

  const systemPrompt = new SystemMessage(`
    You are an intent classifier for an AI agent pipeline.

    Given the conversation history, determine if the user has expressed a clear,
    actionable objective.

    Rules:
    - is_clear = true ONLY if you can extract a specific, self-contained task
    - is_clear = false if the request is vague, incomplete, or requires assumptions
    - If is_clear = true: populate 'objective' with a concise restatement of the
      user's goal. Set  'followup' to null.
    - If is_clear = false: populate 'followup' with a single, specific clarifying
      question. Set 'objective' to null.

    Examples of CLEAR: "Summarize this PDF", "Write a SQL query to find top 10 customers"
    Examples of VAGUE: "Help me with my project", "Do something with this data"
  `);
  const response = await model.invoke([systemPrompt, ...state.messages]);

  if (!response.is_clear && response.followup) {
    return new Command({
      update: {
        messages: [new AIMessage(response.followup)],
      },
      goto: END,
    });
  } else {
    return new Command({
      update: {
        objective: response.objective!,
      },
      goto: PLANNER_NODE,
    });
  }
};

const PLANNER_NODE = "plannerNode";
const plannerNode: GraphNode<OverallState> = async (state, config) => {
  const schema = z.object({
    plan: z.array(z.string()),
  });
  const model = new ChatOpenAI({
    model: "gpt-4.1-mini",
    apiKey: env.OPENAI_API_KEY,
  }).withStructuredOutput(schema);

  const systemPrompt = new SystemMessage(`
    You are planning the next step for the agent.

    Return a plan for the user's objective.
  `);

  const plannerRequest = !state.feedback
    ? new HumanMessage(`Objective: ${state.objective}`)
    : new HumanMessage(`
      Revise the plan based on the user's feedback.

      Objective:
      ${state.objective}

      Previous plan:
      ${state.plan.join("\n")}

      ${!state.feedback ? "" : `User feedback: ${state.feedback}`}
  `);

  const response = await model.invoke([
    systemPrompt,
    ...state.messages,
    plannerRequest,
  ]);

  return new Command({
    update: {
      plan: response.plan,
      feedback: undefined,
    },
    goto: HUMAN_APPOVAL_NODE,
  });
};

export type HumanApprovalResponse =
  | {
      type: "accept";
    }
  | {
      type: "cancel";
      feedback: string | undefined;
    };

export type HumanApprovalRequest = {
  name: string;
  description: string;
  content: string[];
  actions: HumanApprovalResponse[];
};
const HUMAN_APPOVAL_NODE = "humanApprovalNode";
const humanApprovalNode: GraphNode<OverallState> = async (
  state,
): Promise<Command<OverallState>> => {
  const interruptRequest: HumanApprovalRequest = {
    name: "Plan Review",
    description: "Review the plan suggested by the planner",
    content: state.plan,
    actions: [{ type: "accept" }, { type: "cancel", feedback: undefined }],
  };

  const response: HumanApprovalResponse = interrupt(interruptRequest);
  switch (response.type) {
    case "accept":
      return new Command({
        goto: EXECUTOR_NODE,
      });
    case "cancel":
      return new Command({
        goto: PLANNER_NODE,
        update: {
          feedback: response.feedback
            ? `The user rejected the plan. Feedback: ${response.feedback}`
            : "The user rejected the plan.",
        },
      });
  }
};

const EXECUTOR_NODE = "executorNode";
const executorNode: GraphNode<OverallState> = async (state, config) => {
  const taskAndResult: Record<string, string> = {};
  for (const task of state.plan) {
    dispatchCustomEvent("task_start", { task });
    const response = await researcherAgent.invoke({
      task,
    });
    taskAndResult[task] = response.result.text;
    dispatchCustomEvent("task_done", { task });
  }
  return new Command({
    update: {
      completedTaskAndResult: taskAndResult,
    },
  });
};

const SUMMARISE_NODE = "summariseNode";
const summariseNode: GraphNode<OverallState> = async (state, config) => {
  const allResults = Object.values(state.completedTaskAndResult);
  const model = new ChatOpenAI({
    model: "gpt-4.1-mini",
    apiKey: env.OPENAI_API_KEY,
    streaming: true,
  });
  const systemPrompt = new SystemMessage(`
    you are a synthesizer, you take in all this information and respond with the final thing
    `);
  const response = await model.invoke([
    systemPrompt,
    new HumanMessage(allResults.join("\n")),
  ]);

  return new Command({
    update: {
      result: response.text,
    },
  });
};

const workflow = new StateGraph(overallState)
  .addNode(CONVERSATION_NODE, conversationNode, {
    ends: [PLANNER_NODE, END],
  })
  .addNode(PLANNER_NODE, plannerNode)
  .addNode(HUMAN_APPOVAL_NODE, humanApprovalNode, {
    ends: [EXECUTOR_NODE, PLANNER_NODE],
  })
  .addNode(EXECUTOR_NODE, executorNode)
  .addNode(SUMMARISE_NODE, summariseNode)
  .addEdge(START, CONVERSATION_NODE)
  .addEdge(PLANNER_NODE, HUMAN_APPOVAL_NODE)
  .addEdge(EXECUTOR_NODE, SUMMARISE_NODE)
  .addEdge(SUMMARISE_NODE, END);

export const agent = workflow.compile({
  checkpointer: new MemorySaver(),
});

const researcherState = new StateSchema({
  messages: MessagesValue,
  task: z.string(),
  result: z.custom<AIMessage>((val) => val instanceof AIMessage),
});
/**@description researcher sub agent */
const researcherAgent = new StateGraph(researcherState)
  .addNode("researcherNode", async (state) => {
    const model = new ChatOpenAI({
      model: "gpt-4.1-mini",
      apiKey: env.OPENAI_API_KEY,
      streaming: true,
    });
    const RESEARCHER_SYSTEM_MESSAGE = new SystemMessage(`
      You are a research assistant that helps users find and synthesize information on any topic.

      When given a research question:
      1. Synthesize findings into a clear, concise response
      2. Include source links in your answer

      Guidelines:
      - Prioritize recent sources when timeliness matters
      - Present multiple perspectives for debated topics
      - Be transparent about conflicting information or gaps in available data
      - Keep responses focused on answering the specific question asked
      `);

    const response = await model.invoke([
      RESEARCHER_SYSTEM_MESSAGE,
      new HumanMessage(state.task),
    ]);

    return new Command({
      update: {
        result: response,
      },
    });
  })
  .addEdge(START, "researcherNode")
  .addEdge("researcherNode", END)
  .compile();

conclusion

With this architecture, we can choose different models that does its job best. For example, using openai/5.2 on the planning stage, openai/4.1 to perform the execution and so on. You also use model from different providers.

Context management is key, just because its context window is 1M, it doesn’t mean we should fill it up. Overly reliant on the context window can lead to context rot, we don’t want our agent to become incoherent at turn 50.

Lastly, checkout the frontend code for the agent: https://github.com/tanshunyuan/glob-guides/blob/main/bricklaying-about-agents/multi-agent/src/index.tsx, it’ll show you how to hook it up to a UI so that it doesn’t stay as a jupyter notebook prototype.

With that, we came to the end of this series! I hope that it’s been beneficial for the readers. Now you’re equipped with the knowledge of what agents are, how to create a single and multi agent architecture! Hope you have enjoyed it!

If you like these posts, get new ones emailed to you