I would like to standardize the approach for creating and utilizing tools within our project. Currently, some tools are implemented using DynamicStructuredTool directly, while others employ the tool() function to manually return Command objects. Could you please advise which approach would be more suitable for long-term project maintainability and scalability?
given those files index.ts, types.ts, we could state this:
TL;DR: In LangChain JS, tool() is a higher‑level factory that builds DynamicStructuredTool/DynamicTool for you and wires in newer runtime features (state, context, interrupts, cancellation, etc.). For a long‑lived codebase, standardize on tool() for almost everything and only reach for DynamicStructuredTool when you explicitly need low‑level control or custom subclasses.
What is actually different?
1. DynamicStructuredTool is the low‑level class.
- You instantiate it directly:
new DynamicStructuredTool({ name, description, schema, func, ... }).func(input, runManager?, config?)gets called after the schema has been applied.
- It’s just a
StructuredToolInterfaceimplementation: schema validation +_callwrapper, with no extra opinions. - Source: see
DynamicStructuredToolinindex.ts(around lines 450–511).
2. tool() is a convenience factory with extra plumbing.
tool()returns either aDynamicTool(string input) orDynamicStructuredTool(object/JSON schema input) based on the schema you pass.- It accepts functions of the form:
async (input, runtime: ToolRuntime) => ...orasync (input, config: ToolRunnableConfig) => ....
- Internally (
index.tslines ~744–845 andtypes.tslines ~448–548):- Wraps your function in the LangChain/LangGraph execution context via
AsyncLocalStorageProviderSingleton. - Handles
AbortSignalcancellation for tools. - Injects
ToolRuntime(state, context, store, writer, toolCallId, config) when used with LangGraph. - Ensures callbacks / metadata / config are propagated consistently.
- Wraps your function in the LangChain/LangGraph execution context via
- This is the API that current LangChain + LangGraph docs push for defining tools, e.g. the custom tools and interrupts examples (Interrupts - Docs by LangChain).
3. For the LLM, both look the same.
- Both approaches ultimately expose the same things to the model:
name,description, and an argumentschema. - The difference is ergonomics and integration with the broader runtime, not what the model sees.
About returning Command objects
Returning Command is orthogonal to DynamicStructuredTool vs tool().
- A tool’s return type in JS is largely unconstrained (
ToolOutputType), so you can return aCommandfrom either a manually createdDynamicStructuredToolor atool()-created tool. - LangGraph’s runtime special‑cases
Commandin graph nodes; it doesn’t care whether the underlying tool was made vianew DynamicStructuredTool(...)ortool(...). - Where
tool()does help is giving you theToolRuntime(state, context, store, writer), which is extremely useful in non-trivial graphs that useCommandto orchestrate flow.
Recommendation for maintainability & scalability
I would standardize on tool() for project code.
- Pros:
- Less boilerplate; consistent pattern:
const myTool = tool(async (input, runtime) => { ... }, { name, description, schema }). - Automatically gains new LangChain/LangGraph runtime features (state injection, context, better cancellation, streaming via
runtime.writer, etc.) without you changing every tool. - Easier onboarding: new engineers learn one idiomatic pattern that matches the official docs.
- Less boilerplate; consistent pattern:
- Use
DynamicStructuredTooldirectly only when:- You need a custom subclass or to override parts of
StructuredTool/invoke. - You are building a lower‑level library that wants fine‑grained control over callback plumbing or config types.
- You are porting/mirroring legacy code that already works at the
DynamicStructuredToollevel and you have a strong reason not to touch it.
- You need a custom subclass or to override parts of
In a typical app that uses LangChain + LangGraph and returns Command objects from tools, the most future‑proof, maintainable approach is: define all new tools with tool() and gradually migrate existing DynamicStructuredTool usages to that helper unless you have a concrete need for the low‑level API.
Hi @pawel-twardziak ,
thank you so much for taking the time to write such a detailed and thoughtful explanation. This was incredibly helpful — it clarified not just the what, but the why behind tool() vs DynamicStructuredTool, especially around runtime integration and long-term maintainability.
Your breakdown of the internal plumbing (AsyncLocalStorage, cancellation, ToolRuntime injection, etc.) really helped me connect the docs with the actual implementation, and the recommendation section gives a very clear direction for how to structure a real production codebase. It saved me a lot of trial-and-error.
I really appreciate the depth and clarity here. Thanks again — this was a big help! ![]()