- Blogs
- Adobe ColdFusion
- Giving Your ColdFusion AI Tools: Letting the Robot Use Your CFCs Without Handing It a Flamethrower
Master
This article introduces CFC tools as the next layer in building useful AI features with ColdFusion. After covering stateless ChatModel() calls and memory-enabled Agent() conversations, we now give the assistant a controlled way to request real application capabilities through ColdFusion components. The article explains how to expose specific CFC methods as tools, write useful tool descriptions, inspect toolExecutionRequests, validate model-generated arguments, enforce authorization inside the tool, handle read versus write actions, log tool usage, and keep the application firmly in control. The core lesson is simple: tools let the AI ask for real data or actions, but ColdFusion validates, authorizes, executes, and decides what happens next.
In the last few articles, we have been slowly teaching ColdFusion how to talk to AI without immediately causing a production incident.
In the preamble, we covered the vocabulary: LLMs, prompts, tokens, context windows, temperature, hallucinations, tools, RAG, MCP, and guardrails.
Then we built the ColdFusion AI version of “Hello World” using ChatModel(). We configured a model, sent a prompt with .chat(), read response.message, and safely displayed the result.
After that, we moved from stateless calls to Agent(), where we added memory. The assistant could finally remember what the user just said, maintain recent context, and avoid treating every request like a first date.
That was useful, but memory only gets us so far. If a user says:
My ticket number is TKT-12345.
And then asks:
What is the status of my ticket?
Memory can help the assistant remember the ticket number, but it can’t tell us the actual ticket status. The answer is not in the model or the chat history. It’s not floating around in the token soup waiting to be discovered by positive thinking.
The answer is in your application. Your database. Your services. Your CFCs. That’s where tools come in.
Where we are in the stack
So far, our progression looks like this:
ChatModel(): Good for simple, stateless prompts.Agent()with memory: Good for multi-turn conversations and per-user context.Agent()with tools: Good for letting the AI request real application capabilities.
This article is about that third layer.
Function tools allow an AI agent to ask your ColdFusion application to do something. That “something” can be a CFC method that fetches data, performs a calculation, creates a record, validates input, or triggers a workflow.
That is a major upgrade. Without tools, the AI can only answer using the prompt, memory, and model knowledge. With tools, the AI can interact with your actual application.
That is also where the danger level goes up, because there is a large difference between, “the assistant summarized a paragraph,” and “the assistant created 400 support tickets because someone typed ‘please help’ with enthusiasm.”
So we are going to add tools carefully.
What is a function tool?
A function tool is a callable capability exposed to the AI agent. In ColdFusion, that usually means a CFC method. For example, imagine you have a support tool with these methods:
getTicketStatus( ticketId )
createTicket( summary, priority )
The AI doesn’t need direct database access or your datasource credentials. It doesn’t need to know your table names, nor does it need to run SELECT * FROM anything_please. Instead, the AI can request a tool call:
Call getTicketStatus with ticketId = TKT-12345.
Then your ColdFusion application decides what to do. That last part is critical. The model requests the tool. Your application executes the tool. The model is not wandering through your application unsupervised with a clipboard and a suspicious amount of confidence.
Why tools matter
Tools solve a very specific problem… The model can reason about the user’s intent, but your application owns the facts and actions.
A model may understand that the user is asking about a support ticket, but your CFC knows how to look up the ticket.
A model may understand that the user wants to register for a workshop, but your application knows whether registration is open, whether the user is eligible, whether payment is required, and whether the workshop has already been cancelled because someone scheduled it during a long weekend.
A model may understand that the user wants to reset something, but your application decides whether they are allowed to reset it.
Tools let the AI bridge from language to application capability. The assistant can say, “This sounds like a ticket status question. I should request the ticket status tool,” then your ColdFusion code handles the real work.
That is the right division of labour. The AI handles intent and explanation. ColdFusion handles business logic, validation, security, database access, and all the boring things that keep companies out of court.
A simple tool CFC
Let’s start with a deliberately boring CFC. Boring is good. Boring is how we avoid explaining to the CTO why the demo assistant emailed every customer a haiku. Create a CFC called SupportTool.cfc.
omponent output = false {
remote string function getTicketStatus(
required string ticketId
) hint = "Returns the current status of a support ticket by its ID." {
if ( arguments.ticketId == "TKT-12345" ) {
return "in progress";
}
return "closed";
}
remote struct function createTicket(
required string summary,
required string priority
) hint = "Creates a new support ticket with the given summary and priority." {
return {
id : createUUID(),
summary : arguments.summary,
priority : arguments.priority,
status : "new"
};
}
}
This clarly isn’t production code. There is no database, no permission check, no validation beyond required arguments. This code would never see the light of day. It is intentionally tiny so we can focus on the AI tool flow.
In a real application, these methods would call your service layer, validate access, check the current user, log the action, and probably annoy you slightly with edge cases because real applications remain committed to personal growth.
Registering tools with Agent()
Now we register the CFC as a tool when creating the agent.
<cfscript>chatModel = ChatModel( {provider : "openAI",modelName : "gpt-5-nano",apiKey : application.aiApiKey,temperature : 0.3,maxTokens : 700,timeout : 30} );agent = Agent( {CHATMODEL : chatModel,CHATMEMORY : {TYPE : "messageWindowChatMemory",MAXMESSAGES : 20,PERUSER : true},TOOLS : [{CFC : "tools.SupportTool",METHODS : [{METHOD : "getTicketStatus",DESCRIPTION : "Get the status of a support ticket by ID. Use this when the user asks about the progress, state, or status of an existing ticket."},{METHOD : "createTicket",DESCRIPTION : "Create a new support ticket. Use this when the user wants to report a new issue or request help."}]}]} );</cfscript>
The new part is the TOOLS array. This tells the agent, “These are the CFC methods you are allowed to request.” Notice that we are exposing specific methods. That’s intentional.
The Adobe docs show that you can omit the METHODS array and expose all remote methods from a CFC. That may be useful for development or for CFCs built specifically as tool collections, but the docs also call out specific method exposure as the production best practice because it limits what the AI can invoke. (guides.adobe.com)
In other words:
TOOLS : [
{ CFC : "tools.SupportTool" }
]
Might be convenient, but:
TOOLS : [
{
CFC : "tools.SupportTool",
METHODS : [
{ METHOD : "getTicketStatus", DESCRIPTION : "..." }
]
}
]
…is usually safer. And safer is better when you are giving the robot hands.
Descriptions are not decoration
The DESCRIPTION is not just documentation for humans. It helps the model decide when to use the tool. A bad description looks like this:
DESCRIPTION : "Gets ticket."
That is not helpful. Gets ticket what? Status? Owner? Comments? Priority? The emotional state of the ticket? When providing a description, use something more specific:
DESCRIPTION : "Get the status of a support ticket by ID. Use this when the user asks about the progress, state, or status of an existing ticket."
That tells the model when the tool applies. This matters because the model doesn’t browse your source code and develop a deep spiritual understanding of your naming conventions. It sees the tool schema and descriptions.
Good tool descriptions reduce confusion. Bad descriptions produce weird tool choices. No descriptions are an invitation for the model to shrug digitally and make something up.
Tool calls are requests, not automatic execution
This is the part to slow down and read twice. When the model decides it needs a tool, agent.chat() can return a response struct containing toolExecutionRequests. That doesn’t mean the tool has already been safely executed. It means the model is requesting a tool call.
The Adobe docs describe the response as containing a toolExecutionRequests array, with each entry describing the tool name and arguments. Your application is responsible for checking for those requests, executing the tool, and handling the result. (guides.adobe.com)
This is good. It keeps your application in control. For example:
<cfscript>response = agent.chat("What is the status of ticket TKT-12345?",session.sessionId);if (structKeyExists( response, "toolExecutionRequests" )&& arrayLen( response.toolExecutionRequests )) {toolRequest = response.toolExecutionRequests[ 1 ];writeOutput( "Tool requested: " & encodeForHtml( toolRequest.name ) );writeOutput( "<br>" );writeOutput( "Arguments: " & encodeForHtml( serializeJSON( toolRequest.arguments ) ) );} else {writeOutput( encodeForHtml( response.message ) );}</cfscript>
At this point, we are only inspecting the tool request.At this point, we are only inspecting the tool request. That is the first debugging step.
Before executing anything, look at what the model is asking for. This is how you learn whether your tool descriptions are good, whether the model understands the user intent, and whether your expected arguments are coming through correctly.
This is also how you avoid building a feature where the model quietly asks to call deleteCustomer() and everyone learns about it from the audit logs.
Executing the requested tool
Now let’s actually execute the requested tool. For a simple demo, we can map allowed method names to application service calls.
This demonstrates the important application pattern:
<cfparam name="form.message" default=""><cfscript>if ( !structKeyExists( application, "memoryDemoAgent" ) ) {lock scope = "application" type = "exclusive" timeout = 10 {if ( !structKeyExists( application, "memoryDemoAgent" ) ) {chatModel = ChatModel( {provider : "openAI",modelName : "gpt-5-nano",apiKey : application.aiApiKey,temperature : 0.3,maxTokens : 700,timeout : 30} );application.memoryDemoAgent = Agent( {CHATMODEL : chatModel,CHATMEMORY : {TYPE : "messageWindowChatMemory",MAXMESSAGES : 20,PERUSER : true}} );application.memoryDemoAgent.systemMessage("You are a helpful ColdFusion AI assistant. Use CFScript examples when code is helpful. Be concise.");}}}userId = session.sessionId;result = "";if ( len( trim( form.message ) ) ) {try {response = application.memoryDemoAgent.chat(trim( form.message ),userId);result = response.message;} catch ( any error ) {writeLog(file = "ai",type = "error",text = "AI memory demo failed: #error.message#");result = "Sorry, I could not generate a response right now.";}}</cfscript><cfoutput><form method="post"><label for="message">Message</label><br><textarea id="message" name="message" rows="5" cols="80">#encodeForHtml( form.message )#</textarea><br><button type="submit">Send </button></form><cfif len( result )><h2>Response</h2><pre>#encodeForHtml( result )#</pre></cfif></cfoutput>
Ask the agent. Check whether the agent requested a tool. Verify the tool is allowed. Validate the arguments. Execute the CFC method. Handle the result.
That is the safe mental model.
Do not blindly do this:
evaluate( "application.supportTool.#toolRequest.name#()" );
No. Bad. Put that down.
Dynamic execution based on model output is how you turn a nice tutorial into a security training video. Use explicit allowlists. Use a switch. Use validation. Use normal code.
ColdFusion did not survive decades of production applications so we could hand evaluate() to a chatbot and hope for the best.
Returning tool results to the model
For a full conversational tool workflow, your application usually needs to send the tool result back to the model so it can explain the result to the user. Conceptually, the flow looks like this:
User:What is the status of ticket TKT-12345?Agent:I need to call getTicketStatus with ticketId TKT-12345.ColdFusion:Checks the request.Executes getTicketStatus.Gets "in progress".Agent:Explains to the user:Your ticket TKT-12345 is currently in progress.
The exact implementation details depend on the supported response and continuation pattern in your ColdFusion version and provider behavior. The architectural point is the important part… The AI model should not be the source of truth. The tool result should be. The assistant’s final answer should be based on what the tool returned, not what the model guessed before the tool ran.
For simple demos, you can display the tool result directly. For a polished assistant, you will generally pass the tool result back into the conversation and ask the model to produce a user-friendly answer. For example:
<cfscript>toolSummaryPrompt = "The user asked: What is the status of ticket TKT-12345?The application looked up the ticket and returned this result:#serializeJSON( toolResult )#Answer the user in one short sentence.";finalResponse = agent.chat(toolSummaryPrompt,session.sessionId);writeOutput( encodeForHtml( finalResponse.message ) );</cfscript>
That’s intentionally simple. In a real implementation, you may want a more structured continuation flow, but the principle remains:
- Tool first.
- Model explains second.
Not the other way around.
Tool arguments are untrusted input
The model generates tool arguments. That means tool arguments are external input. Treat them like form fields. Because that is basically what they are, except instead of being typed into a form by a user, they were inferred by a probabilistic model that may or may not have had enough coffee.
Validate everything. For Example:
<cfscript>if ( !structKeyExists( toolRequest.arguments, "ticketId" ) ) {throw(type = "AiTool.MissingArgument",message = "The ticketId argument is required.");}ticketId = trim( toolRequest.arguments.ticketId );if ( !reFindNoCase( "^TKT-[0-9]{5}$", ticketId ) ) {throw(type = "AiTool.InvalidArgument",message = "The ticketId argument is invalid.");}</cfscript>
Do not assume:
- the argument exists
- the argument is the right type
- the argument belongs to the current user
- the argument is safe
- the argument is complete
- the argument should be trusted because “the AI said so”
- The AI does not get a hall pass.
Authorization belongs inside the tool
This is the most important production rule in the article: Tools must enforce authorization. Not the prompt. Not the model. Not the UI. The tool.
If getTicketStatus() returns information about a support ticket, it should verify that the current user is allowed to see that ticket. That means the tool probably needs user context.
A better production-style method might look like this:
component output = false {public struct function getTicketStatus(required numeric userId,required string ticketId) {var ticket = application.ticketService.getTicketByPublicId(ticketId = arguments.ticketId);if ( !application.ticketService.userCanViewTicket(userId = arguments.userId,ticketId = ticket.id) ) {throw(type = "Security.NotAuthorized",message = "The current user is not allowed to view this ticket.");}return {ticketId : ticket.publicId,status : ticket.status,lastUpdated : ticket.updatedAt};}}
Then when executing the tool request, your application injects the authenticated user ID.
toolResult = application.supportTool.getTicketStatus(
userId = session.userId,
ticketId = toolRequest.arguments.ticketId
);
Do not let the model provide userId. The user does not get to say, “actually, I am user ID 1.” The model doesn’t get to pass that along like it came down from the mountain on stone tablets. The application knows the authenticated user. The application injects that context. The tool verifies authorization. That is the pattern.
Read tools versus write tools
Not all tools are equal. Some tools read data. Some tools change data. That difference matters.
Read tools:
- get ticket status
- list upcoming events
- calculate shipping estimate
- check registration availability
- retrieve account summary
- validate coupon code
Write tools:
- create ticket
- cancel registration
- send email
- update profile
- issue refund
- delete record
- post announcement
Read tools can still leak sensitive information, so they require authorization. But write tools can actively change the world. That means they need even more care. For write tools, consider:
- confirmation steps
- explicit user approval
- audit logging
- idempotency
- rate limits
- permission checks
- validation
- rollback strategy
- human review for high-risk actions
A good first tool is usually a read tool. Do not make your first AI tool refundAllOrders(). That may create excitement, but not the good kind.
Confirmation before writes
For write actions, use a confirmation flow. Consider the following example conversation:
User:
Create a high priority ticket saying I cannot log in.
Assistant:
I can create that ticket. Please confirm:
Summary: I cannot log in
Priority: High
User:
Yes, create it.
Application:
Executes createTicket().
This gives the user a chance to catch misunderstandings, because the model might infer the wrong priority. Or summarize badly. Or create something too broad. Or misunderstand “I can’t log in to staging” as “production is on fire,” which, to be fair, sometimes it is.
The pattern should be:
- Model extracts intended action.
- Application presents confirmation.
- User confirms.
- Application executes the write tool.
- Assistant summarizes the result.
This is not just user-friendly, it’s also a safety mechanism.
Tool design tips
Good tools are boring, specific, and constrained. That is a compliment. A good AI tool method should:
- do one thing
- have a clear name
- use typed arguments
- validate inputs
- enforce authorization
- return structured data
- avoid exposing internal implementation details
- avoid requiring the model to know database IDs
- avoid broad “do anything” behavior
Bad tool:
public any function runSql( required string sql )
Absolutely not. No. That is not a tool. That is a resignation letter written in CFML.
Better tool:
public struct function getOrderStatus(
required numeric userId,
required string orderNumber
)
Specific. Constrained. Understandable. Testable. Auditable. Less likely to become a conference talk titled “Lessons Learned.”
Return structured data
Tool methods should usually return structured data. For example:
return {
ticketId : ticket.publicId,
status : ticket.status,
priority : ticket.priority,
lastUpdated : dateTimeFormat( ticket.updatedAt, "yyyy-mm-dd HH:nn:ss" )
};
This gives the model clean facts to explain. Avoid returning huge blobs of HTML, raw database rows, internal field names, or giant nested structures unless the model actually needs them. The model does not need your entire ticket object. It probably needs:
- ticket ID
- status
- priority
- last update
- maybe next step
Return the minimum useful information. This keeps prompts smaller, safer, and easier for the model to use. It also avoids the classic enterprise pattern where a method named getSummary() returns 4 MB of “just in case.”
Keep internal details internal
Do not expose internal identifiers unless the user needs them. Bad result:
return {
id : 4815162342,
internalQueueId : 7,
databaseShard : "legacy-east-2",
statusId : 3,
assignedUserId : 99
};
Better result:
return {
ticketId : "TKT-12345",
status : "in progress",
assignedTeam : "Support",
lastUpdated : "2026-06-26 09:15:00"
};
The AI should not accidentally tell the user, “your ticket is in status_id 3 on database shard legacy-east-2.” Nobody wants that, except possibly the person who created legacy-east-2, and even they would prefer not to discuss it.
A fuller example
Let’s assemble a more realistic demo. First, the tool CFC:
component output = false {
public struct function getTicketStatus(
required numeric userId,
required string ticketId
)
hint = "Returns the current status of a support ticket if the authenticated user is allowed to view it."
{
var normalizedTicketId = uCase( trim( arguments.ticketId ) );
if ( !reFindNoCase( "^TKT-[0-9]{5}$", normalizedTicketId ) ) {
throw(
type = "AiTool.InvalidTicketId",
message = "Invalid ticket ID."
);
}
// Demo data. Replace with a real service/database lookup.
if ( normalizedTicketId != "TKT-12345" ) {
return {
ticketId : normalizedTicketId,
found : false,
message : "No matching ticket was found."
};
}
// Demo authorization. Replace with real permission logic.
if ( arguments.userId <= 0 ) {
throw(
type = "Security.NotAuthorized",
message = "The current user is not authorized."
);
}
return {
ticketId : normalizedTicketId,
found : true,
status : "in progress",
priority : "normal",
lastUpdated : "2026-06-26 09:15:00"
};
}
}
Now the page:
<cfparam name="form.message" default="">
<cfscript>
result = "";
if ( !structKeyExists( application, "supportTool" ) ) {
application.supportTool = new tools.SupportTool();
}
if ( !structKeyExists( application, "toolDemoAgent" ) ) {
lock scope = "application" type = "exclusive" timeout = 10 {
if ( !structKeyExists( application, "toolDemoAgent" ) ) {
chatModel = ChatModel( {
provider : "openAI",
modelName : "gpt-5-nano",
apiKey : application.aiApiKey,
temperature : 0.3,
maxTokens : 700,
timeout : 30
} );
application.toolDemoAgent = Agent( {
CHATMODEL : chatModel,
CHATMEMORY : {
TYPE : "messageWindowChatMemory",
MAXMESSAGES : 20,
PERUSER : true
},
TOOLS : [
{
CFC : "tools.SupportTool",
METHODS : [
{
METHOD : "getTicketStatus",
DESCRIPTION : "Get the current status of a support ticket by ticket ID. Use this when the user asks about the progress, state, or status of an existing support ticket."
}
]
}
]
} );
application.toolDemoAgent.systemMessage(
"You are a helpful support assistant. Use tools when current application data is needed. Do not guess ticket status."
);
}
}
}
if ( len( trim( form.message ) ) ) {
try {
response = application.toolDemoAgent.chat(
trim( form.message ),
session.sessionId
);
if (
structKeyExists( response, "toolExecutionRequests" )
&& arrayLen( response.toolExecutionRequests )
) {
toolRequest = response.toolExecutionRequests[ 1 ];
switch ( toolRequest.name ) {
case "getTicketStatus":
if ( !structKeyExists( toolRequest.arguments, "ticketId" ) ) {
throw(
type = "AiTool.MissingArgument",
message = "Ticket ID is required."
);
}
toolResult = application.supportTool.getTicketStatus(
userId = session.userId,
ticketId = toolRequest.arguments.ticketId
);
finalPrompt = "
The user asked:
#trim( form.message )#
The application returned this ticket status result:
#serializeJSON( toolResult )#
Answer the user in one short, helpful paragraph.
Do not add facts that are not in the tool result.
";
finalResponse = application.toolDemoAgent.chat(
finalPrompt,
session.sessionId
);
result = finalResponse.message;
break;
default:
throw(
type = "AiTool.UnsupportedTool",
message = "Unsupported tool requested: #toolRequest.name#"
);
}
} else {
result = response.message;
}
} catch ( any error ) {
writeLog(
file = "ai",
type = "error",
text = "AI tool demo failed: #error.message#"
);
result = "Sorry, I could not complete that request right now.";
}
}
</cfscript>
<cfoutput>
<form method="post">
<label for="message">Message</label>
<br>
<textarea
id="message"
name="message"
rows="5"
cols="80"
>#encodeForHtml( form.message )#</textarea>
<br>
<button type="submit">
Send
</button>
</form>
<cfif len( result )>
<h2>Response</h2>
<pre>#encodeForHtml( result )#</pre>
</cfif>
</cfoutput>
Try asking:
What is the status of ticket TKT-12345?
The agent should recognize that this requires current application data and request the getTicketStatus tool. Your application then executes the tool, gets the result, and asks the agent to produce a user-friendly response.
Again, this is a demo. The important part is not the fake ticket data. The important part is the boundary:
- The model requests.
- ColdFusion validates.
- ColdFusion executes.
- ColdFusion decides what gets returned.
The model explains.
But why not let the model call everything?
Because models aren’t security boundaries. They’re very useful, but they are not permission systems. They do not know which methods are safe. They do not know which arguments are sensitive. They do not know whether a user is allowed to perform an action. They do not know whether deleteOldRecords() means “delete stale draft previews” or “remove half the company’s customer history.”
Your application knows… or at least it should. If it does not, please pause this article and go have a meaningful conversation with your service layer.
Exposing all remote methods may be convenient while experimenting, but in production, expose only the methods the AI should be allowed to request. The fact that a method is remotely accessible in CFML does not mean it is appropriate as an AI tool. Remote to your application is not the same as available to the robot.
Tool descriptions should include when to use the tool
A common mistake is describing what the method does but not when the model should use it. Less useful:
DESCRIPTION : "Returns ticket status."
More useful:
DESCRIPTION : "Get the current status of a support ticket by ticket ID. Use this when the user asks about the progress, state, or status of an existing support ticket."
Even better, include examples if helpful:
DESCRIPTION : "Get the current status of a support ticket by ticket ID. Use this when the user asks questions like 'What is happening with ticket TKT-12345?' or 'Is my support ticket resolved yet?'"
The tool description is part of the model’s decision-making context. Treat it like a mini instruction manual. Not like a comment you wrote while emotionally finished with the sprint.
Keep tools small
Small tools are easier for the model to use and easier for you to secure.
Good: getTicketStatus( userId, ticketId )
Good: listUpcomingEvents( userId, startDate, endDate )
Good: calculateCartTotal( userId, cartId )
Suspicious: handleUserRequest( input )
Deeply suspicious: doEverything( payload )
Absolutely not: runArbitraryCode( code )
The model should not have one giant magical method where anything can happen. That’s not a tool. That’s a portal. And portals are how movies start.
Use application services underneath
Your tool CFC does not need to contain all business logic. In fact, it usually should not. A tool CFC can be a thin wrapper over existing application services. For example:
component output = false {
public order_tool function init(
required any orderService,
required any securityService
) {
variables.orderService = arguments.orderService;
variables.securityService = arguments.securityService;
return this;
}
public struct function getOrderStatus(
required numeric userId,
required string orderNumber
) {
var order = variables.orderService.getByOrderNumber(
orderNumber = arguments.orderNumber
);
if ( !variables.securityService.userCanViewOrder(
userId = arguments.userId,
orderId = order.id
) ) {
throw(
type = "Security.NotAuthorized",
message = "The current user cannot view this order."
);
}
return {
orderNumber : order.orderNumber,
status : order.status,
placedAt : order.placedAt,
total : order.totalFormatted
};
}
}
This keeps your AI integration from becoming a parallel universe version of your business logic. You already have services, use them. Don’t copy-paste business rules into the tool CFC because “it was just a quick demo.” That sentence has created more technical debt than any database migration ever written at 1:00 a.m.
Tool errors should be user-safe
Tools fail.
- Ticket not found.
- User not authorized.
- Missing argument.
- Invalid date.
- Service unavailable.
- Database timeout.
- Someone renamed the staging API again and left no witnesses.
Do not expose raw errors to users. Catch errors and translate them into safe responses. For example:
try {
toolResult = application.supportTool.getTicketStatus(
userId = session.userId,
ticketId = toolRequest.arguments.ticketId
);
} catch ( Security.NotAuthorized error ) {
toolResult = {
success : false,
errorCode : "not_authorized",
message : "The current user is not allowed to view this ticket."
};
} catch ( AiTool.InvalidTicketId error ) {
toolResult = {
success : false,
errorCode : "invalid_ticket_id",
message : "The ticket ID format is invalid."
};
} catch ( any error ) {
writeLog(
file = "ai-tools",
type = "error",
text = "Ticket status tool failed: #error.message#"
);
toolResult = {
success : false,
errorCode : "tool_failed",
message : "The ticket status could not be retrieved right now."
};
}
Then the assistant can explain the safe result. The user doesn’t need a stack trace. The model doesn’t need a stack trace. Nobody needs a stack trace in the chat window. That is what logs are for.
Log tool usage
Tool calls are important events. Log them. Useful metadata includes:
- user ID
- tenant/account/group ID
- conversation ID
- requested tool name
- sanitized arguments
- whether the request was allowed
- whether execution succeeded
- latency
- error code
- request ID
Be careful logging raw arguments. If a tool accepts user content, ticket descriptions, email text, or other sensitive information, sanitize or redact as appropriate. For example:
writeLog(
file = "ai-tools",
type = "information",
text = "AI tool requested. userId=#session.userId# tool=#toolRequest.name#"
);
For production, structured logs are better. But even basic logs are better than discovering your AI assistant has been requesting tools for three weeks and nobody knows which ones. Observability is not optional. It is how future-you learns what past-you unleashed.
Tools and memory together
Tools become more useful when combined with our previous article topic, memory. For example:
User:
My ticket is TKT-12345.
Assistant:
Got it.
User:
What is the status?
Assistant:
Uses memory to know the ticket ID.
Requests getTicketStatus.
Answers with current status.
Memory provides conversation context. Tools provide application facts. This is exactly the kind of layered behavior we want. But remember: memory can remember a ticket number. The tool must still verify the current user can access that ticket. Memory makes the assistant coherent. Tools make the assistant useful. Authorization makes the assistant safe. Skip that last one and your assistant becomes an eager intern with a badge printer.
Tools and hallucinations
Tools also reduce hallucinations. Without a tool, the assistant might answer, “Your ticket is probably being reviewed.” That is not good. “Probably” is not a ticket status.
With a tool, the assistant can say, “Your ticket TKT-12345 is currently in progress,” because that status came from your application. This is one of the biggest benefits of tools. They ground the assistant in actual application state. Not model vibes. Not training data. Not a confident guess wearing a necktie. Actual data.
Tools are not RAG
Tools and RAG solve different problems. Use tools when the answer requires application behavior or structured data. For example:
- What is my order status?
- Am I registered?
- What is my account balance?
- Create a support ticket.
- Calculate shipping.
- List my upcoming events.
Use RAG when the answer requires retrieving relevant document content. for example:
- What does the refund policy say?
- How do I configure SSO?
- What are the registration rules?
- What does the employee handbook say about remote work?
- How does this API endpoint work?
You can combine them. A user might ask, “Can I cancel my registration and get a refund?“
That may require:
- a tool to check the user’s registration
- a tool to check payment/refund status
- RAG to retrieve the refund policy
- a final AI response explaining the result
That is later-series territory. For now, tools are how the assistant talks to your application. RAG is how the assistant talks to your documents. Please do not confuse the two unless you enjoy building systems that are both expensive and wrong.
Tools are not MCP
CFC tools are local to your ColdFusion application. MCP is a standardized protocol for exposing and consuming tools, prompts, and resources across systems. Use CFC tools when:
- the logic lives in your ColdFusion app
- you want a simple local integration
- you are exposing application services directly
- you do not need a separate tool server or protocol boundary
Use MCP when:
- tools live outside your application
- multiple clients need the same tools
- you want standardized tool discovery
- tools should be shared across applications
- enterprise governance/auditability requires a protocol layer
Again, they can work together. ColdFusion can expose CFC logic through MCP, and ColdFusion agents can use MCP clients. But for this article, CFC tools are the simpler and more direct starting point.
We will cover MCP next.
That is when the robot starts getting a passport.
Common mistakes
Let’s review the obvious ways this can go sideways.
- Exposing too many methods. Do not expose an entire CFC unless every public method is intentionally safe for AI use. Use
METHODS. Be specific. The robot does not need access to your whole toolbox. Especially not the chainsaw. - Bad descriptions. Descriptions should tell the model when to use the tool, not just what the method does. Bad descriptions make the model guess. The model is already good enough at guessing. That is the problem.
- Trusting arguments. Tool arguments come from the model. Validate them like user input. Because they are user input after being passed through a language blender.
- Letting the model provide security context. Do not let the model decide
userId,accountId, role, tenant, permissions, or ownership. Your application provides that context. Always. - Using broad write tools. Avoid tools like: updateUser( userData ) Or: processRequest( action, payload ). Prefer narrow, explicit tools.
- No confirmation for writes. For any action that changes data, sends messages, charges money, cancels things, deletes things, or annoys humans, require confirmation. The model should not get to mutate production because it sounded sure.
- No logging. Tool calls should be logged. If the assistant can request application actions, you need an audit trail.
- Returning too much data. Do not return massive internal objects. Return the minimum structured data needed to answer the user. Your model does not need your entire schema. Neither does the user. Honestly, some developers do not need it either, but that is a separate article.
- A better first tool feature. A good first CFC tool feature is read-only and low risk. For example:
-
- check ticket status
- list upcoming events
- calculate an estimate
- retrieve a public order summary
- validate whether registration is open
- check whether a username is available
- summarize account settings already visible to the user
Avoid making your first tool feature:
- send email
- delete records
- issue refunds
- update billing
- change permissions
- post public content
- modify production configuration
- trigger batch jobs with names like
final_cleanup_REAL.cfm
Start with read-only. Then add write tools carefully, with confirmation and audit logging. This is not cowardice. This is engineering.
Where we go next
At this point, our assistant can do more than answer from memory. It can request controlled access to application capabilities. That is a big step. We now have:
ChatModel() for simple stateless generation
Agent() for memory and multi-turn conversations
CFC tools for application actions and real data. But CFC tools are still local to our application. What happens when tools live somewhere else? What happens when multiple applications need to share the same tools? What happens when you want a standard protocol for exposing and consuming tools, prompts, and resources?
That is where MCP comes in.
In the next article, we will introduce Model Context Protocol and look at how it fits into ColdFusion AI applications. CFC tools let the assistant use your application. MCP helps the assistant use an ecosystem. That sounds dramatic, but mostly it means we get to add another acronym to the pile.
Final thought
Tools are where AI starts to feel genuinely useful in an application. A model can explain. Memory can remember. But tools let the assistant do something with your actual application capabilities.
That is powerful.
It is also risky if you treat the model like a trusted operator. So don’t. Expose specific methods. Write clear descriptions. Validate arguments. Inject authenticated user context from your application. Enforce authorization inside the tool. Confirm write actions. Log tool usage. Return only the data the model needs.
And remember the rule that keeps this whole series from becoming a postmortem:
The AI can ask… ColdFusion decides.
In the last few articles, we have been slowly teaching ColdFusion how to talk to AI without immediately causing a production incident.
In the preamble, we covered the vocabulary: LLMs, prompts, tokens, context windows, temperature, hallucinations, tools, RAG, MCP, and guardrails.
Then we built the ColdFusion AI version of “Hello World” using ChatModel(). We configured a model, sent a prompt with .chat(), read response.message, and safely displayed the result.
After that, we moved from stateless calls to Agent(), where we added memory. The assistant could finally remember what the user just said, maintain recent context, and avoid treating every request like a first date.
That was useful, but memory only gets us so far. If a user says:
My ticket number is TKT-12345.
And then asks:
What is the status of my ticket?
Memory can help the assistant remember the ticket number, but it can’t tell us the actual ticket status. The answer is not in the model or the chat history. It’s not floating around in the token soup waiting to be discovered by positive thinking.
The answer is in your application. Your database. Your services. Your CFCs. That’s where tools come in.
Where we are in the stack
So far, our progression looks like this:
ChatModel(): Good for simple, stateless prompts.Agent()with memory: Good for multi-turn conversations and per-user context.Agent()with tools: Good for letting the AI request real application capabilities.
This article is about that third layer.
Function tools allow an AI agent to ask your ColdFusion application to do something. That “something” can be a CFC method that fetches data, performs a calculation, creates a record, validates input, or triggers a workflow.
That is a major upgrade. Without tools, the AI can only answer using the prompt, memory, and model knowledge. With tools, the AI can interact with your actual application.
That is also where the danger level goes up, because there is a large difference between, “the assistant summarized a paragraph,” and “the assistant created 400 support tickets because someone typed ‘please help’ with enthusiasm.”
So we are going to add tools carefully.
What is a function tool?
A function tool is a callable capability exposed to the AI agent. In ColdFusion, that usually means a CFC method. For example, imagine you have a support tool with these methods:
getTicketStatus( ticketId )
createTicket( summary, priority )
The AI doesn’t need direct database access or your datasource credentials. It doesn’t need to know your table names, nor does it need to run SELECT * FROM anything_please. Instead, the AI can request a tool call:
Call getTicketStatus with ticketId = TKT-12345.
Then your ColdFusion application decides what to do. That last part is critical. The model requests the tool. Your application executes the tool. The model is not wandering through your application unsupervised with a clipboard and a suspicious amount of confidence.
Why tools matter
Tools solve a very specific problem… The model can reason about the user’s intent, but your application owns the facts and actions.
A model may understand that the user is asking about a support ticket, but your CFC knows how to look up the ticket.
A model may understand that the user wants to register for a workshop, but your application knows whether registration is open, whether the user is eligible, whether payment is required, and whether the workshop has already been cancelled because someone scheduled it during a long weekend.
A model may understand that the user wants to reset something, but your application decides whether they are allowed to reset it.
Tools let the AI bridge from language to application capability. The assistant can say, “This sounds like a ticket status question. I should request the ticket status tool,” then your ColdFusion code handles the real work.
That is the right division of labour. The AI handles intent and explanation. ColdFusion handles business logic, validation, security, database access, and all the boring things that keep companies out of court.
A simple tool CFC
Let’s start with a deliberately boring CFC. Boring is good. Boring is how we avoid explaining to the CTO why the demo assistant emailed every customer a haiku. Create a CFC called SupportTool.cfc.
omponent output = false {
remote string function getTicketStatus(
required string ticketId
) hint = "Returns the current status of a support ticket by its ID." {
if ( arguments.ticketId == "TKT-12345" ) {
return "in progress";
}
return "closed";
}
remote struct function createTicket(
required string summary,
required string priority
) hint = "Creates a new support ticket with the given summary and priority." {
return {
id : createUUID(),
summary : arguments.summary,
priority : arguments.priority,
status : "new"
};
}
}
This clarly isn’t production code. There is no database, no permission check, no validation beyond required arguments. This code would never see the light of day. It is intentionally tiny so we can focus on the AI tool flow.
In a real application, these methods would call your service layer, validate access, check the current user, log the action, and probably annoy you slightly with edge cases because real applications remain committed to personal growth.
Registering tools with Agent()
Now we register the CFC as a tool when creating the agent.
<cfscript>chatModel = ChatModel( {provider : "openAI",modelName : "gpt-5-nano",apiKey : application.aiApiKey,temperature : 0.3,maxTokens : 700,timeout : 30} );agent = Agent( {CHATMODEL : chatModel,CHATMEMORY : {TYPE : "messageWindowChatMemory",MAXMESSAGES : 20,PERUSER : true},TOOLS : [{CFC : "tools.SupportTool",METHODS : [{METHOD : "getTicketStatus",DESCRIPTION : "Get the status of a support ticket by ID. Use this when the user asks about the progress, state, or status of an existing ticket."},{METHOD : "createTicket",DESCRIPTION : "Create a new support ticket. Use this when the user wants to report a new issue or request help."}]}]} );</cfscript>
The new part is the TOOLS array. This tells the agent, “These are the CFC methods you are allowed to request.” Notice that we are exposing specific methods. That’s intentional.
The Adobe docs show that you can omit the METHODS array and expose all remote methods from a CFC. That may be useful for development or for CFCs built specifically as tool collections, but the docs also call out specific method exposure as the production best practice because it limits what the AI can invoke. (guides.adobe.com)
In other words:
TOOLS : [
{ CFC : "tools.SupportTool" }
]
Might be convenient, but:
TOOLS : [
{
CFC : "tools.SupportTool",
METHODS : [
{ METHOD : "getTicketStatus", DESCRIPTION : "..." }
]
}
]
…is usually safer. And safer is better when you are giving the robot hands.
Descriptions are not decoration
The DESCRIPTION is not just documentation for humans. It helps the model decide when to use the tool. A bad description looks like this:
DESCRIPTION : "Gets ticket."
That is not helpful. Gets ticket what? Status? Owner? Comments? Priority? The emotional state of the ticket? When providing a description, use something more specific:
DESCRIPTION : "Get the status of a support ticket by ID. Use this when the user asks about the progress, state, or status of an existing ticket."
That tells the model when the tool applies. This matters because the model doesn’t browse your source code and develop a deep spiritual understanding of your naming conventions. It sees the tool schema and descriptions.
Good tool descriptions reduce confusion. Bad descriptions produce weird tool choices. No descriptions are an invitation for the model to shrug digitally and make something up.
Tool calls are requests, not automatic execution
This is the part to slow down and read twice. When the model decides it needs a tool, agent.chat() can return a response struct containing toolExecutionRequests. That doesn’t mean the tool has already been safely executed. It means the model is requesting a tool call.
The Adobe docs describe the response as containing a toolExecutionRequests array, with each entry describing the tool name and arguments. Your application is responsible for checking for those requests, executing the tool, and handling the result. (guides.adobe.com)
This is good. It keeps your application in control. For example:
<cfscript>response = agent.chat("What is the status of ticket TKT-12345?",session.sessionId);if (structKeyExists( response, "toolExecutionRequests" )&& arrayLen( response.toolExecutionRequests )) {toolRequest = response.toolExecutionRequests[ 1 ];writeOutput( "Tool requested: " & encodeForHtml( toolRequest.name ) );writeOutput( "<br>" );writeOutput( "Arguments: " & encodeForHtml( serializeJSON( toolRequest.arguments ) ) );} else {writeOutput( encodeForHtml( response.message ) );}</cfscript>
At this point, we are only inspecting the tool request.At this point, we are only inspecting the tool request. That is the first debugging step.
Before executing anything, look at what the model is asking for. This is how you learn whether your tool descriptions are good, whether the model understands the user intent, and whether your expected arguments are coming through correctly.
This is also how you avoid building a feature where the model quietly asks to call deleteCustomer() and everyone learns about it from the audit logs.
Executing the requested tool
Now let’s actually execute the requested tool. For a simple demo, we can map allowed method names to application service calls.
This demonstrates the important application pattern:
<cfparam name="form.message" default=""><cfscript>if ( !structKeyExists( application, "memoryDemoAgent" ) ) {lock scope = "application" type = "exclusive" timeout = 10 {if ( !structKeyExists( application, "memoryDemoAgent" ) ) {chatModel = ChatModel( {provider : "openAI",modelName : "gpt-5-nano",apiKey : application.aiApiKey,temperature : 0.3,maxTokens : 700,timeout : 30} );application.memoryDemoAgent = Agent( {CHATMODEL : chatModel,CHATMEMORY : {TYPE : "messageWindowChatMemory",MAXMESSAGES : 20,PERUSER : true}} );application.memoryDemoAgent.systemMessage("You are a helpful ColdFusion AI assistant. Use CFScript examples when code is helpful. Be concise.");}}}userId = session.sessionId;result = "";if ( len( trim( form.message ) ) ) {try {response = application.memoryDemoAgent.chat(trim( form.message ),userId);result = response.message;} catch ( any error ) {writeLog(file = "ai",type = "error",text = "AI memory demo failed: #error.message#");result = "Sorry, I could not generate a response right now.";}}</cfscript><cfoutput><form method="post"><label for="message">Message</label><br><textarea id="message" name="message" rows="5" cols="80">#encodeForHtml( form.message )#</textarea><br><button type="submit">Send </button></form><cfif len( result )><h2>Response</h2><pre>#encodeForHtml( result )#</pre></cfif></cfoutput>
Ask the agent. Check whether the agent requested a tool. Verify the tool is allowed. Validate the arguments. Execute the CFC method. Handle the result.
That is the safe mental model.
Do not blindly do this:
evaluate( "application.supportTool.#toolRequest.name#()" );
No. Bad. Put that down.
Dynamic execution based on model output is how you turn a nice tutorial into a security training video. Use explicit allowlists. Use a switch. Use validation. Use normal code.
ColdFusion did not survive decades of production applications so we could hand evaluate() to a chatbot and hope for the best.
Returning tool results to the model
For a full conversational tool workflow, your application usually needs to send the tool result back to the model so it can explain the result to the user. Conceptually, the flow looks like this:
User:What is the status of ticket TKT-12345?Agent:I need to call getTicketStatus with ticketId TKT-12345.ColdFusion:Checks the request.Executes getTicketStatus.Gets "in progress".Agent:Explains to the user:Your ticket TKT-12345 is currently in progress.
The exact implementation details depend on the supported response and continuation pattern in your ColdFusion version and provider behavior. The architectural point is the important part… The AI model should not be the source of truth. The tool result should be. The assistant’s final answer should be based on what the tool returned, not what the model guessed before the tool ran.
For simple demos, you can display the tool result directly. For a polished assistant, you will generally pass the tool result back into the conversation and ask the model to produce a user-friendly answer. For example:
<cfscript>toolSummaryPrompt = "The user asked: What is the status of ticket TKT-12345?The application looked up the ticket and returned this result:#serializeJSON( toolResult )#Answer the user in one short sentence.";finalResponse = agent.chat(toolSummaryPrompt,session.sessionId);writeOutput( encodeForHtml( finalResponse.message ) );</cfscript>
That’s intentionally simple. In a real implementation, you may want a more structured continuation flow, but the principle remains:
- Tool first.
- Model explains second.
Not the other way around.
Tool arguments are untrusted input
The model generates tool arguments. That means tool arguments are external input. Treat them like form fields. Because that is basically what they are, except instead of being typed into a form by a user, they were inferred by a probabilistic model that may or may not have had enough coffee.
Validate everything. For Example:
<cfscript>if ( !structKeyExists( toolRequest.arguments, "ticketId" ) ) {throw(type = "AiTool.MissingArgument",message = "The ticketId argument is required.");}ticketId = trim( toolRequest.arguments.ticketId );if ( !reFindNoCase( "^TKT-[0-9]{5}$", ticketId ) ) {throw(type = "AiTool.InvalidArgument",message = "The ticketId argument is invalid.");}</cfscript>
Do not assume:
- the argument exists
- the argument is the right type
- the argument belongs to the current user
- the argument is safe
- the argument is complete
- the argument should be trusted because “the AI said so”
- The AI does not get a hall pass.
Authorization belongs inside the tool
This is the most important production rule in the article: Tools must enforce authorization. Not the prompt. Not the model. Not the UI. The tool.
If getTicketStatus() returns information about a support ticket, it should verify that the current user is allowed to see that ticket. That means the tool probably needs user context.
A better production-style method might look like this:
component output = false {public struct function getTicketStatus(required numeric userId,required string ticketId) {var ticket = application.ticketService.getTicketByPublicId(ticketId = arguments.ticketId);if ( !application.ticketService.userCanViewTicket(userId = arguments.userId,ticketId = ticket.id) ) {throw(type = "Security.NotAuthorized",message = "The current user is not allowed to view this ticket.");}return {ticketId : ticket.publicId,status : ticket.status,lastUpdated : ticket.updatedAt};}}
Then when executing the tool request, your application injects the authenticated user ID.
toolResult = application.supportTool.getTicketStatus(
userId = session.userId,
ticketId = toolRequest.arguments.ticketId
);
Do not let the model provide userId. The user does not get to say, “actually, I am user ID 1.” The model doesn’t get to pass that along like it came down from the mountain on stone tablets. The application knows the authenticated user. The application injects that context. The tool verifies authorization. That is the pattern.
Read tools versus write tools
Not all tools are equal. Some tools read data. Some tools change data. That difference matters.
Read tools:
- get ticket status
- list upcoming events
- calculate shipping estimate
- check registration availability
- retrieve account summary
- validate coupon code
Write tools:
- create ticket
- cancel registration
- send email
- update profile
- issue refund
- delete record
- post announcement
Read tools can still leak sensitive information, so they require authorization. But write tools can actively change the world. That means they need even more care. For write tools, consider:
- confirmation steps
- explicit user approval
- audit logging
- idempotency
- rate limits
- permission checks
- validation
- rollback strategy
- human review for high-risk actions
A good first tool is usually a read tool. Do not make your first AI tool refundAllOrders(). That may create excitement, but not the good kind.
Confirmation before writes
For write actions, use a confirmation flow. Consider the following example conversation:
User:
Create a high priority ticket saying I cannot log in.
Assistant:
I can create that ticket. Please confirm:
Summary: I cannot log in
Priority: High
User:
Yes, create it.
Application:
Executes createTicket().
This gives the user a chance to catch misunderstandings, because the model might infer the wrong priority. Or summarize badly. Or create something too broad. Or misunderstand “I can’t log in to staging” as “production is on fire,” which, to be fair, sometimes it is.
The pattern should be:
- Model extracts intended action.
- Application presents confirmation.
- User confirms.
- Application executes the write tool.
- Assistant summarizes the result.
This is not just user-friendly, it’s also a safety mechanism.
Tool design tips
Good tools are boring, specific, and constrained. That is a compliment. A good AI tool method should:
- do one thing
- have a clear name
- use typed arguments
- validate inputs
- enforce authorization
- return structured data
- avoid exposing internal implementation details
- avoid requiring the model to know database IDs
- avoid broad “do anything” behavior
Bad tool:
public any function runSql( required string sql )
Absolutely not. No. That is not a tool. That is a resignation letter written in CFML.
Better tool:
public struct function getOrderStatus(
required numeric userId,
required string orderNumber
)
Specific. Constrained. Understandable. Testable. Auditable. Less likely to become a conference talk titled “Lessons Learned.”
Return structured data
Tool methods should usually return structured data. For example:
return {
ticketId : ticket.publicId,
status : ticket.status,
priority : ticket.priority,
lastUpdated : dateTimeFormat( ticket.updatedAt, "yyyy-mm-dd HH:nn:ss" )
};
This gives the model clean facts to explain. Avoid returning huge blobs of HTML, raw database rows, internal field names, or giant nested structures unless the model actually needs them. The model does not need your entire ticket object. It probably needs:
- ticket ID
- status
- priority
- last update
- maybe next step
Return the minimum useful information. This keeps prompts smaller, safer, and easier for the model to use. It also avoids the classic enterprise pattern where a method named getSummary() returns 4 MB of “just in case.”
Keep internal details internal
Do not expose internal identifiers unless the user needs them. Bad result:
return {
id : 4815162342,
internalQueueId : 7,
databaseShard : "legacy-east-2",
statusId : 3,
assignedUserId : 99
};
Better result:
return {
ticketId : "TKT-12345",
status : "in progress",
assignedTeam : "Support",
lastUpdated : "2026-06-26 09:15:00"
};
The AI should not accidentally tell the user, “your ticket is in status_id 3 on database shard legacy-east-2.” Nobody wants that, except possibly the person who created legacy-east-2, and even they would prefer not to discuss it.
A fuller example
Let’s assemble a more realistic demo. First, the tool CFC:
component output = false {
public struct function getTicketStatus(
required numeric userId,
required string ticketId
)
hint = "Returns the current status of a support ticket if the authenticated user is allowed to view it."
{
var normalizedTicketId = uCase( trim( arguments.ticketId ) );
if ( !reFindNoCase( "^TKT-[0-9]{5}$", normalizedTicketId ) ) {
throw(
type = "AiTool.InvalidTicketId",
message = "Invalid ticket ID."
);
}
// Demo data. Replace with a real service/database lookup.
if ( normalizedTicketId != "TKT-12345" ) {
return {
ticketId : normalizedTicketId,
found : false,
message : "No matching ticket was found."
};
}
// Demo authorization. Replace with real permission logic.
if ( arguments.userId <= 0 ) {
throw(
type = "Security.NotAuthorized",
message = "The current user is not authorized."
);
}
return {
ticketId : normalizedTicketId,
found : true,
status : "in progress",
priority : "normal",
lastUpdated : "2026-06-26 09:15:00"
};
}
}
Now the page:
<cfparam name="form.message" default="">
<cfscript>
result = "";
if ( !structKeyExists( application, "supportTool" ) ) {
application.supportTool = new tools.SupportTool();
}
if ( !structKeyExists( application, "toolDemoAgent" ) ) {
lock scope = "application" type = "exclusive" timeout = 10 {
if ( !structKeyExists( application, "toolDemoAgent" ) ) {
chatModel = ChatModel( {
provider : "openAI",
modelName : "gpt-5-nano",
apiKey : application.aiApiKey,
temperature : 0.3,
maxTokens : 700,
timeout : 30
} );
application.toolDemoAgent = Agent( {
CHATMODEL : chatModel,
CHATMEMORY : {
TYPE : "messageWindowChatMemory",
MAXMESSAGES : 20,
PERUSER : true
},
TOOLS : [
{
CFC : "tools.SupportTool",
METHODS : [
{
METHOD : "getTicketStatus",
DESCRIPTION : "Get the current status of a support ticket by ticket ID. Use this when the user asks about the progress, state, or status of an existing support ticket."
}
]
}
]
} );
application.toolDemoAgent.systemMessage(
"You are a helpful support assistant. Use tools when current application data is needed. Do not guess ticket status."
);
}
}
}
if ( len( trim( form.message ) ) ) {
try {
response = application.toolDemoAgent.chat(
trim( form.message ),
session.sessionId
);
if (
structKeyExists( response, "toolExecutionRequests" )
&& arrayLen( response.toolExecutionRequests )
) {
toolRequest = response.toolExecutionRequests[ 1 ];
switch ( toolRequest.name ) {
case "getTicketStatus":
if ( !structKeyExists( toolRequest.arguments, "ticketId" ) ) {
throw(
type = "AiTool.MissingArgument",
message = "Ticket ID is required."
);
}
toolResult = application.supportTool.getTicketStatus(
userId = session.userId,
ticketId = toolRequest.arguments.ticketId
);
finalPrompt = "
The user asked:
#trim( form.message )#
The application returned this ticket status result:
#serializeJSON( toolResult )#
Answer the user in one short, helpful paragraph.
Do not add facts that are not in the tool result.
";
finalResponse = application.toolDemoAgent.chat(
finalPrompt,
session.sessionId
);
result = finalResponse.message;
break;
default:
throw(
type = "AiTool.UnsupportedTool",
message = "Unsupported tool requested: #toolRequest.name#"
);
}
} else {
result = response.message;
}
} catch ( any error ) {
writeLog(
file = "ai",
type = "error",
text = "AI tool demo failed: #error.message#"
);
result = "Sorry, I could not complete that request right now.";
}
}
</cfscript>
<cfoutput>
<form method="post">
<label for="message">Message</label>
<br>
<textarea
id="message"
name="message"
rows="5"
cols="80"
>#encodeForHtml( form.message )#</textarea>
<br>
<button type="submit">
Send
</button>
</form>
<cfif len( result )>
<h2>Response</h2>
<pre>#encodeForHtml( result )#</pre>
</cfif>
</cfoutput>
Try asking:
What is the status of ticket TKT-12345?
The agent should recognize that this requires current application data and request the getTicketStatus tool. Your application then executes the tool, gets the result, and asks the agent to produce a user-friendly response.
Again, this is a demo. The important part is not the fake ticket data. The important part is the boundary:
- The model requests.
- ColdFusion validates.
- ColdFusion executes.
- ColdFusion decides what gets returned.
The model explains.
But why not let the model call everything?
Because models aren’t security boundaries. They’re very useful, but they are not permission systems. They do not know which methods are safe. They do not know which arguments are sensitive. They do not know whether a user is allowed to perform an action. They do not know whether deleteOldRecords() means “delete stale draft previews” or “remove half the company’s customer history.”
Your application knows… or at least it should. If it does not, please pause this article and go have a meaningful conversation with your service layer.
Exposing all remote methods may be convenient while experimenting, but in production, expose only the methods the AI should be allowed to request. The fact that a method is remotely accessible in CFML does not mean it is appropriate as an AI tool. Remote to your application is not the same as available to the robot.
Tool descriptions should include when to use the tool
A common mistake is describing what the method does but not when the model should use it. Less useful:
DESCRIPTION : "Returns ticket status."
More useful:
DESCRIPTION : "Get the current status of a support ticket by ticket ID. Use this when the user asks about the progress, state, or status of an existing support ticket."
Even better, include examples if helpful:
DESCRIPTION : "Get the current status of a support ticket by ticket ID. Use this when the user asks questions like 'What is happening with ticket TKT-12345?' or 'Is my support ticket resolved yet?'"
The tool description is part of the model’s decision-making context. Treat it like a mini instruction manual. Not like a comment you wrote while emotionally finished with the sprint.
Keep tools small
Small tools are easier for the model to use and easier for you to secure.
Good: getTicketStatus( userId, ticketId )
Good: listUpcomingEvents( userId, startDate, endDate )
Good: calculateCartTotal( userId, cartId )
Suspicious: handleUserRequest( input )
Deeply suspicious: doEverything( payload )
Absolutely not: runArbitraryCode( code )
The model should not have one giant magical method where anything can happen. That’s not a tool. That’s a portal. And portals are how movies start.
Use application services underneath
Your tool CFC does not need to contain all business logic. In fact, it usually should not. A tool CFC can be a thin wrapper over existing application services. For example:
component output = false {
public order_tool function init(
required any orderService,
required any securityService
) {
variables.orderService = arguments.orderService;
variables.securityService = arguments.securityService;
return this;
}
public struct function getOrderStatus(
required numeric userId,
required string orderNumber
) {
var order = variables.orderService.getByOrderNumber(
orderNumber = arguments.orderNumber
);
if ( !variables.securityService.userCanViewOrder(
userId = arguments.userId,
orderId = order.id
) ) {
throw(
type = "Security.NotAuthorized",
message = "The current user cannot view this order."
);
}
return {
orderNumber : order.orderNumber,
status : order.status,
placedAt : order.placedAt,
total : order.totalFormatted
};
}
}
This keeps your AI integration from becoming a parallel universe version of your business logic. You already have services, use them. Don’t copy-paste business rules into the tool CFC because “it was just a quick demo.” That sentence has created more technical debt than any database migration ever written at 1:00 a.m.
Tool errors should be user-safe
Tools fail.
- Ticket not found.
- User not authorized.
- Missing argument.
- Invalid date.
- Service unavailable.
- Database timeout.
- Someone renamed the staging API again and left no witnesses.
Do not expose raw errors to users. Catch errors and translate them into safe responses. For example:
try {
toolResult = application.supportTool.getTicketStatus(
userId = session.userId,
ticketId = toolRequest.arguments.ticketId
);
} catch ( Security.NotAuthorized error ) {
toolResult = {
success : false,
errorCode : "not_authorized",
message : "The current user is not allowed to view this ticket."
};
} catch ( AiTool.InvalidTicketId error ) {
toolResult = {
success : false,
errorCode : "invalid_ticket_id",
message : "The ticket ID format is invalid."
};
} catch ( any error ) {
writeLog(
file = "ai-tools",
type = "error",
text = "Ticket status tool failed: #error.message#"
);
toolResult = {
success : false,
errorCode : "tool_failed",
message : "The ticket status could not be retrieved right now."
};
}
Then the assistant can explain the safe result. The user doesn’t need a stack trace. The model doesn’t need a stack trace. Nobody needs a stack trace in the chat window. That is what logs are for.
Log tool usage
Tool calls are important events. Log them. Useful metadata includes:
- user ID
- tenant/account/group ID
- conversation ID
- requested tool name
- sanitized arguments
- whether the request was allowed
- whether execution succeeded
- latency
- error code
- request ID
Be careful logging raw arguments. If a tool accepts user content, ticket descriptions, email text, or other sensitive information, sanitize or redact as appropriate. For example:
writeLog(
file = "ai-tools",
type = "information",
text = "AI tool requested. userId=#session.userId# tool=#toolRequest.name#"
);
For production, structured logs are better. But even basic logs are better than discovering your AI assistant has been requesting tools for three weeks and nobody knows which ones. Observability is not optional. It is how future-you learns what past-you unleashed.
Tools and memory together
Tools become more useful when combined with our previous article topic, memory. For example:
User:
My ticket is TKT-12345.
Assistant:
Got it.
User:
What is the status?
Assistant:
Uses memory to know the ticket ID.
Requests getTicketStatus.
Answers with current status.
Memory provides conversation context. Tools provide application facts. This is exactly the kind of layered behavior we want. But remember: memory can remember a ticket number. The tool must still verify the current user can access that ticket. Memory makes the assistant coherent. Tools make the assistant useful. Authorization makes the assistant safe. Skip that last one and your assistant becomes an eager intern with a badge printer.
Tools and hallucinations
Tools also reduce hallucinations. Without a tool, the assistant might answer, “Your ticket is probably being reviewed.” That is not good. “Probably” is not a ticket status.
With a tool, the assistant can say, “Your ticket TKT-12345 is currently in progress,” because that status came from your application. This is one of the biggest benefits of tools. They ground the assistant in actual application state. Not model vibes. Not training data. Not a confident guess wearing a necktie. Actual data.
Tools are not RAG
Tools and RAG solve different problems. Use tools when the answer requires application behavior or structured data. For example:
- What is my order status?
- Am I registered?
- What is my account balance?
- Create a support ticket.
- Calculate shipping.
- List my upcoming events.
Use RAG when the answer requires retrieving relevant document content. for example:
- What does the refund policy say?
- How do I configure SSO?
- What are the registration rules?
- What does the employee handbook say about remote work?
- How does this API endpoint work?
You can combine them. A user might ask, “Can I cancel my registration and get a refund?“
That may require:
- a tool to check the user’s registration
- a tool to check payment/refund status
- RAG to retrieve the refund policy
- a final AI response explaining the result
That is later-series territory. For now, tools are how the assistant talks to your application. RAG is how the assistant talks to your documents. Please do not confuse the two unless you enjoy building systems that are both expensive and wrong.
Tools are not MCP
CFC tools are local to your ColdFusion application. MCP is a standardized protocol for exposing and consuming tools, prompts, and resources across systems. Use CFC tools when:
- the logic lives in your ColdFusion app
- you want a simple local integration
- you are exposing application services directly
- you do not need a separate tool server or protocol boundary
Use MCP when:
- tools live outside your application
- multiple clients need the same tools
- you want standardized tool discovery
- tools should be shared across applications
- enterprise governance/auditability requires a protocol layer
Again, they can work together. ColdFusion can expose CFC logic through MCP, and ColdFusion agents can use MCP clients. But for this article, CFC tools are the simpler and more direct starting point.
We will cover MCP next.
That is when the robot starts getting a passport.
Common mistakes
Let’s review the obvious ways this can go sideways.
- Exposing too many methods. Do not expose an entire CFC unless every public method is intentionally safe for AI use. Use
METHODS. Be specific. The robot does not need access to your whole toolbox. Especially not the chainsaw. - Bad descriptions. Descriptions should tell the model when to use the tool, not just what the method does. Bad descriptions make the model guess. The model is already good enough at guessing. That is the problem.
- Trusting arguments. Tool arguments come from the model. Validate them like user input. Because they are user input after being passed through a language blender.
- Letting the model provide security context. Do not let the model decide
userId,accountId, role, tenant, permissions, or ownership. Your application provides that context. Always. - Using broad write tools. Avoid tools like: updateUser( userData ) Or: processRequest( action, payload ). Prefer narrow, explicit tools.
- No confirmation for writes. For any action that changes data, sends messages, charges money, cancels things, deletes things, or annoys humans, require confirmation. The model should not get to mutate production because it sounded sure.
- No logging. Tool calls should be logged. If the assistant can request application actions, you need an audit trail.
- Returning too much data. Do not return massive internal objects. Return the minimum structured data needed to answer the user. Your model does not need your entire schema. Neither does the user. Honestly, some developers do not need it either, but that is a separate article.
- A better first tool feature. A good first CFC tool feature is read-only and low risk. For example:
-
- check ticket status
- list upcoming events
- calculate an estimate
- retrieve a public order summary
- validate whether registration is open
- check whether a username is available
- summarize account settings already visible to the user
Avoid making your first tool feature:
- send email
- delete records
- issue refunds
- update billing
- change permissions
- post public content
- modify production configuration
- trigger batch jobs with names like
final_cleanup_REAL.cfm
Start with read-only. Then add write tools carefully, with confirmation and audit logging. This is not cowardice. This is engineering.
Where we go next
At this point, our assistant can do more than answer from memory. It can request controlled access to application capabilities. That is a big step. We now have:
ChatModel() for simple stateless generation
Agent() for memory and multi-turn conversations
CFC tools for application actions and real data. But CFC tools are still local to our application. What happens when tools live somewhere else? What happens when multiple applications need to share the same tools? What happens when you want a standard protocol for exposing and consuming tools, prompts, and resources?
That is where MCP comes in.
In the next article, we will introduce Model Context Protocol and look at how it fits into ColdFusion AI applications. CFC tools let the assistant use your application. MCP helps the assistant use an ecosystem. That sounds dramatic, but mostly it means we get to add another acronym to the pile.
Final thought
Tools are where AI starts to feel genuinely useful in an application. A model can explain. Memory can remember. But tools let the assistant do something with your actual application capabilities.
That is powerful.
It is also risky if you treat the model like a trusted operator. So don’t. Expose specific methods. Write clear descriptions. Validate arguments. Inject authenticated user context from your application. Enforce authorization inside the tool. Confirm write actions. Log tool usage. Return only the data the model needs.
And remember the rule that keeps this whole series from becoming a postmortem:
The AI can ask… ColdFusion decides.
Master
- Most Recent
- Most Relevant




