# AI agent audit trail
> An AI agent audit trail is a record of every action an AI agent takes or tries to take in your app, with the person it acted for, the agent or client that made the call, the tool and arguments, the record it touched, the outcome and the time.

An AI agent audit trail is a record of every action an AI agent takes or tries to take in your app: who the agent acted for, which agent or client made the call, what it tried to do, on which record, what happened, and when. It answers the same question as any audit log, "who did this?", for work that a person asked for but did not do by hand.

The trail is not there because agents misbehave. They act on someone's behalf, and a week later that person, their lead or an auditor will want to read back what was done in their name.

## How it differs from an ordinary audit log

An ordinary audit log records what people did: Ada edited this invoice at 14:02. If you want that general picture for a Rails app, the [Rails audit trail page](https://avohq.io/audit-logging) covers it. An agent trail adds three things a human log can leave out.

- **Two actors per entry.** A person clicked the button in a human log. In an agent log, a person authorized the agent and the agent chose the call. You need both names.
- **Attempts, not only changes.** A human rarely sees a button they cannot press, because the UI hides it. An agent can ask for any tool it knows about, so denied and failed calls happen often enough to be worth reading.
- **Reads matter more.** A person browsing an Index view leaves little worth logging. An agent that reads 400 customer records to answer one question is a data access event you may need to account for.

## What an entry should record

Each entry is one tool call. Record these fields.

- **The person.** The user whose permissions the call ran with. Usually the person who connected the client or started the chat.
- **The agent or client.** Which connection made the call: its ID, the client's name (Claude Code, Cursor, an internal bot), and the credential it used. A person may have three connections; you want to know which one.
- **What it tried.** The tool or action name and its arguments, filtered (more on that below).
- **The target.** The model and ID of the record it touched, or the scope it searched.
- **The outcome.** Succeeded, denied, or failed, plus a short reason for the last two.
- **When.** A timestamp, plus the request ID so you can join the entry to your application logs.

## A Rails setup

The model below is plain Active Record. It assumes your app already has a `User` model and a table of agent connections (an OAuth grant, an API token, whatever your app issues to clients).

```ruby
# db/migrate/20261112090000_create_agent_activities.rb
class CreateAgentActivities < ActiveRecord::Migration[8.0]
  def change
    create_table :agent_activities do |t|
      t.references :user, null: false, foreign_key: true
      t.references :agent_connection, null: false, foreign_key: true
      t.string :client_name, null: false
      t.string :tool, null: false
      t.jsonb :arguments, null: false, default: {}
      t.references :target, polymorphic: true
      t.string :outcome, null: false
      t.string :reason
      t.string :request_id
      t.datetime :created_at, null: false
    end

    add_index :agent_activities, [:user_id, :created_at]
    add_index :agent_activities, [:outcome, :created_at]
  end
end
```

There is no `updated_at`. An audit row is written once and never edited.

```ruby
# app/models/agent_activity.rb
class AgentActivity < ApplicationRecord
  belongs_to :user
  belongs_to :agent_connection
  belongs_to :target, polymorphic: true, optional: true

  enum :outcome, {succeeded: "succeeded", denied: "denied", failed: "failed"}

  FILTER = ActiveSupport::ParameterFilter.new(
    Rails.application.config.filter_parameters + [:prompt, :message]
  )

  before_validation { self.arguments = FILTER.filter(arguments.to_h) }

  def readonly?
    persisted?
  end
end
```

`ActiveSupport::ParameterFilter` replaces matching values with `"[FILTERED]"`. Plain symbols match anywhere in a key, so `:token` also catches `access_token`. Reusing `config.filter_parameters` keeps the trail in step with what your logs already hide.

Then wrap every tool call in one place, so nothing reaches your data without leaving a row.

```ruby
# app/services/agent_tool_call.rb
class AgentToolCall
  def initialize(connection:, tool:, arguments:, request_id:)
    @connection, @tool, @arguments, @request_id = connection, tool, arguments, request_id
  end

  def run
    result = yield
    record(:succeeded, target: result.try(:record))
    result
  rescue AccessDenied => e
    record(:denied, reason: e.message)
    raise
  rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e
    record(:failed, reason: e.message.truncate(250))
    raise
  end

  private

  def record(outcome, target: nil, reason: nil)
    AgentActivity.create!(
      user: @connection.user,
      agent_connection: @connection,
      client_name: @connection.client_name,
      tool: @tool,
      arguments: @arguments,
      target: target,
      outcome: outcome,
      reason: reason,
      request_id: @request_id
    )
  end
end
```

`AccessDenied` stands for whatever your authorization layer raises. Re-raising after the write keeps the caller's error handling unchanged.

## Recording who made the call and who it was for

Store both, and never let one stand in for the other. If you store only the user, "Ada deleted 40 records" reads as if Ada did it by hand, and you cannot tell her laptop agent from her chat session. If you store only the client, you lose the person accountable for the access it had.

This works when every agent connection belongs to one person and runs with that person's permissions. A shared service account with broad rights makes the trail say "the bot did it" with nobody behind it. Tie each connection to a person and give it no more than the task needs: [least privilege for AI agents](https://avohq.io/glossary/least-privilege-for-ai-agents) covers that side.

When an action needed sign-off, add the approver as a third name. [Human in the loop](https://avohq.io/glossary/human-in-the-loop) covers recording who approved what.

## Log denied and failed calls

Yes, log them. A denied call is the system working, and it is also the most useful line in the trail.

- A run of denials from one connection means its grant is too narrow for the job, or the agent is reaching for tools it should not need. Either way you want to know.
- A failed validation tells you the agent is sending bad arguments, often because a tool description is vague.
- When someone asks "could the agent have done X?", a denied entry is your evidence that it tried and was stopped.

## What not to store

An audit trail is kept for a long time and read by more people than your app database. Keep it lean.

- **Secrets.** No tokens, passwords, API keys or session cookies, in arguments or in reasons.
- **Full prompts and chat transcripts.** They often carry personal data the user typed in passing. Store the tool call the prompt produced, not the conversation.
- **Whole records.** Store the target's type and ID. If you need the before and after values of a change, a versioning gem such as paper_trail keeps those in its own table.
- **Raw tool results.** A search that returned 400 customers needs a count, not 400 rows.

## Retention

Pick a retention period on purpose and write it down. It depends on your contracts, your industry and the laws that apply to you, and it is a call for your team and counsel, not a default. Logging obligations do exist for some AI systems under some laws, so check with counsel what applies to yours.

Once you have a number, enforce it with a scheduled job instead of a promise.

```ruby
# app/jobs/purge_agent_activities_job.rb
class PurgeAgentActivitiesJob < ApplicationJob
  def perform(older_than: 1.year)
    AgentActivity.where(created_at: ...older_than.ago).in_batches.delete_all
  end
end
```

`delete_all` skips callbacks, which is what you want here, and it is unaffected by the `readonly?` guard, which only stops changes made through model instances.

## The trail in your Avo app

Teams on Avo get much of this from the foundation they already run. With [Avo's audit logging](https://avohq.io/audit-logging), a change made through the [MCP server](https://avohq.io/addons/mcp), the API or the AI chat is recorded under the person whose permissions it ran with (the admin who authorized the MCP connection, the token's owner, or the chat's owner) and tagged with its origin, linked to that connection, token or chat. The record's Timeline then reads "through MCP connection" next to the clicks. Each MCP connection is granted Read, Read and write, or Run actions per resource, and an admin can change or revoke it from the Avo app.

## Frequently asked questions

### What should an AI agent's audit log record?

One entry per tool call, with the person the agent acted for, the agent or client that made the call, the tool and its filtered arguments, the record it touched, whether it succeeded, was denied or failed, and the time. A request ID lets you join the entry to your application logs.

### How do you attribute an action to both the agent and the user?

Give every agent connection an owner, run each call with that owner's permissions, and store both the user and the connection on the audit entry. Avoid shared service accounts, because their entries name a bot with no accountable person behind it.

### Should denied agent calls be logged?

Yes. Denied calls show where a connection's access is too narrow or where an agent reaches for tools it should not need, and they are your evidence that an attempt was stopped.

### Should I store the agent's prompt in the audit trail?

Usually not. Prompts and transcripts often contain personal data the user typed in passing, and the trail is kept long and read widely. Store the tool call the prompt produced, with secrets filtered out.

### Is an agent audit trail legally required?

It depends on the system and the jurisdiction. Some laws place logging obligations on some AI systems, so check with counsel what applies to yours; this page is not legal advice.
