Logging What AI Agents Do in Salesforce: A Simple One-Object Audit Framework
Log every AI agent action to one custom object, and force the LLM to include a reasoning field in every tool call so you always know why it did what it did.
Join the DZone community and get the full member experience.
Join For FreePicture a simple scenario. An AI agent is wired into your Case page in Salesforce. A customer sends a reply that sounds like the issue is resolved. The agent reads the conversation, decides the case can be closed, and updates the status to "Closed."
A week later, the customer calls in frustrated. "Why did you close my case? My issue wasn't resolved."
You go to the Case in Salesforce to investigate. The audit trail tells you almost nothing useful. CreatedBy says "Integration User." LastModifiedBy says the same. Field history tracking shows the status changed from "Open" to "Closed" at 3:37 pm last Tuesday. None of it tells you the one thing you actually need to know: why did the agent do that?
This is the gap that shows up the minute AI agents start taking real action inside Salesforce. The fix is small. One custom object, a handful of fields, and a habit of asking the agent to explain itself.
Where the Problem Begins
Standard Salesforce auditing was built for two kinds of actors: human users and deterministic automation. When a human closes a case, you can ask them why. When a Flow closes a case, the criteria are sitting right there in the metadata. When an Apex trigger closes a case, the logic is in the code.
AI agents are neither of those. The decision to close the case lived inside a language model's response, and the moment the action committed, that reasoning was gone. Unless you captured it.
The Idea
Drop a single custom object into the org. Every time the agent does something, it writes one row. That row captures three things:
- What the agent did
- What context it had when deciding
- Why it made the call
That's the whole framework. One object, one row per action, three buckets of data.
The Core Object: Agent_Action_Log__c
Here are the fields that matter. Everything else is optional polish.
- Triggering User – Lookup to the User whose interaction caused the agent to run.
- Related Case – Lookup to the Case the agent was working on.
- Action Type – Picklist with values like Create Record, Update Record, Send Email, Call External API.
- Tool Called – The specific Apex method, Flow, or API the agent invoked.
- Inputs – The arguments the agent passed to the tool (long text).
- Context Snapshot – The relevant context the agent had at decision time, such as record state and recent activity (long text).
- Reasoning – The agent's stated rationale, captured verbatim from the LLM response (long text).
- Confidence – A number between 0 and 1 if the agent reports one. Optional but useful for reporting.
- Status – Success or Failure.
- Error – If anything went wrong, the error message lands here.
Eleven fields. One object. Keep it small until you need it to be bigger. Because Related Case is a lookup field, every Case page can show a related list of Agent Action Logs.
How the Reasoning Actually Gets Captured
This is the part most teams get wrong, so it's worth slowing down on.
The LLM doesn't volunteer its reasoning. You have to ask for it as part of the response itself. The way you do that is by defining every tool the agent can call with a JSON schema, and adding a reasoning field to that schema right next to the actual arguments.
Back to the case-closing example. Normally the update_case_status tool would accept two arguments: case_id and new_status. With this framework, add a third required field:
{
"type": "function",
"function": {
"name": "update_case_status",
"description": "Updates the status of a Salesforce Case.",
"parameters": {
"type": "object",
"properties": {
"case_id": {
"type": "string",
"description": "The 18-character Salesforce Case Id."
},
"new_status": {
"type": "string",
"description": "The new status value to apply."
},
"reasoning": {
"type": "string",
"description": "Why this status change is appropriate given the context."
}
},
"required": ["case_id", "new_status", "reasoning"]
}
}
}
Here's the important part. The LLM is not calling Salesforce. Your Apex code calls the LLM, the LLM sends back a JSON payload describing the tool call (function name plus arguments), and your Apex code is the one that actually executes the action. So when the response comes back with case_id, new_status, and reasoning, your code reads all three out of the JSON. The first two drive the update on the Case. The third gets written into the Reasoning field on the new Agent_Action_Log__c record. The agent's words land in your audit log verbatim.
If you turn on strict mode in your tool definition (OpenAI's strict: true), the model is forced to return every required field, including reasoning. That makes empty reasoning rare. Even so, defensive code should reject any tool call missing the field and ask the model to retry, in case strict enforcement isn't available on the path you're using.
The reasoning field is the difference between "the agent closed this case" and "the agent closed this case because the customer's last message said 'Thanks, that solved it, you can close this out.'" One of those is an audit log entry. The other is actually useful.
How It All Works
Walking through the case-closing scenario end to end:
- A customer reply lands on a Case, triggering the agent.
- The framework gathers context about the Case: the Triggering User, the Related Case, and a Context Snapshot showing the last few messages on the case.
- The agent calls
update_case_statuswith case_id, new_status set to "Closed", and a reasoning string. The framework now has everything it needs: Action Type, Tool Called, Inputs, Reasoning, and Confidence. - The framework executes the actual status update on the Case.
- The framework writes one
Agent_Action_Log__crow capturing everything: the context, the reasoning, and the outcome (Status set to "Success", or Error if the update failed). - If the agent takes a follow-up action (say, sending the customer a notification email), that's a new row on the same Case, with its own reasoning.
From any Case page, the related list shows every agent action that ever touched that record. From a Salesforce report, compliance can pull every "Close Case" action where Confidence was below 0.7 in the last quarter. From a debugger's view, you can find out exactly what the agent thought it was doing the moment it did it.
Why This Approach Works
- One object, one place to look. No traversing relationships, no joining across logs.
- The "why" lives next to the "what." Reasoning is captured at decision time, not reconstructed after the fact.
Final Thoughts
The case-closing example is intentionally small. But the same pattern handles every other agent action, whether that's sending an email, escalating to a specialist, updating a field, or posting a notification. Each one becomes another row on the same object, with the same fields, captured the same way.
That's the whole point. You don't need an elaborate observability stack to make AI agents auditable in Salesforce. You need one custom object, a habit of forcing the model to explain itself, and a wrapper around your tool calls that writes a row every time.
The next time someone asks why the agent did something, the answer is already sitting in a record on the page they're looking at.
Opinions expressed by DZone contributors are their own.
Comments