Audit Logger

A lightweight, framework-agnostic agent action logger. Log locally by default. Connect to AgentLair for persistent, queryable audit trails.

Available as @agentlair/audit-logger on npm — zero runtime dependencies, works in Node ≥ 18, Bun, Deno, and modern browsers.


Install

npm install @agentlair/audit-logger
# or
bun add @agentlair/audit-logger

Quick start

import { auditLog } from "@agentlair/audit-logger";

// Logs to console — no config needed
await auditLog({
  agent: "customer-support-bot",
  action: "respond",
  tool: "knowledge_base_search",
  input: { query: "How do I cancel my subscription?" },
  output: { answer: "Go to Settings → Billing → Cancel." },
});
// → {"agent":"customer-support-bot","action":"respond",...,"timestamp":"2026-04-01T12:00:00.000Z"}

No API key, no sign-up, no configuration required for local logging.


Pre-bound logger

createAuditLogger binds the agent name so you don’t repeat it on every call:

import { createAuditLogger } from "@agentlair/audit-logger";

const log = createAuditLogger("inventory-agent");

await log({ action: "check_stock", tool: "db_query", input: { sku: "ABC-123" } });
await log({ action: "reorder", output: { orderId: "PO-9999" } });

Before/after action receipts (AAR split)

The standard auditLog() emits a single post-action record. For tamper-evidence and pre-authorization anchoring, use beginAction / endAction to emit two chained receipts per tool call — one before execution begins, one terminal receipt when the attempt closes.

import { AuditLogger } from "@agentlair/audit-logger";

const logger = new AuditLogger({
  actorId: "agent-researcher",
  hmacSecret: process.env.AUDIT_HMAC_SECRET, // optional signing key
});

// 1. Emit the pre-action receipt BEFORE execution begins.
//    expiresAt is authority data — covered by previousReceiptHash,
//    so tampering with the deadline after signing breaks the chain.
const preAction = await logger.beginAction({
  toolName: "web_search",
  toolCallId: "call-abc123",
  input: { query: "latest AI agent frameworks" },
  approvalDecision: "approved",     // from preflight_trust_check
  policyRef: "trust-check:xyz-789", // link to the trust check result
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
});

// 2. Execute the tool
const startedAt = new Date();
let output: unknown;
let error: Error | undefined;
try {
  output = await webSearch({ query: "latest AI agent frameworks" });
} catch (err) {
  error = err as Error;
} finally {
  const endedAt = new Date();

  // 3. Seal the attempt with a terminal receipt.
  //    phase defaults to "executed"; pass "failed" / "denied" / "expired" / "cancelled"
  //    when the attempt closes without successful execution.
  await logger.endAction({
    preAction,
    phase: error ? "failed" : "executed",
    startedAt,
    endedAt,
    output, // undefined on error
    error,  // undefined on success
  });
}

Why two receipts?

A single post-action log proves nothing about intent — a compromised agent can construct a plausible receipt for an action it never authorized. The pre-action receipt is signed and chained before execution begins. To forge it, an adversary must predict the future or have already compromised the signing key.

ReceiptPhaseWhat it proves
AARPreActionBefore executionInput digest, authorization decision, deadline, chain position
AARTerminalReceiptAttempt closedTerminal phase, link back to pre-action, result/error digest

Every beginAction closes exactly once. A denied or expired attempt is first-class evidence, not an absence — the chain detects omissions.

Terminal phase enum (v0.3+)

endAction({ phase }) accepts one of:

PhaseMeaning
executedTool ran to completion. resultDigest carries the output hash.
failedTool ran but threw. errorClass / errorDigest carry the failure.
deniedPolicy refused before execution. No executionStartedAt.
expiredAuthority window elapsed before completion. terminalAt >= expiresAt.
cancelledCaller withdrew the attempt before completion.

Sign-time invariants enforced at endAction():

  • phase: 'executed' over approvalDecision: 'denied' throws. Executed-over-denied is structurally impossible to mint.
  • phase: 'executed' whose executionEndedAt > preAction.expiresAt throws (v0.4). The logger refuses to claim authorized execution past the deadline.

verifyChain() returns chainIntegrity: 'incomplete' for any pre-action missing a terminal receipt, and 'broken' for late-executed historical chains.

Chain mechanics

Each receipt includes a previousReceiptHash (SHA-256 of the canonical-JSON prior receipt payload). The chain grows linearly:

AARPreAction (tool 1)        → previousReceiptHash = undefined (chain start)
AARTerminalReceipt (tool 1)  → previousReceiptHash = hash(AARPreAction tool 1)
AARPreAction (tool 2)        → previousReceiptHash = hash(terminal of tool 1)
AARTerminalReceipt (tool 2)  → previousReceiptHash = hash(AARPreAction tool 2)

Denied, expired, and cancelled terminals participate in the chain just like executed ones — omitting any of them breaks the successor’s hash and is detectable by any verifier.


Connect to AgentLair

Set AGENTLAIR_API_KEY to ship audit logs to AgentLair for persistent storage and querying.

export AGENTLAIR_API_KEY=aal_...

That’s it. All auditLog() calls will now also POST to AgentLair asynchronously — non-blocking, fire-and-forget. Your agent’s performance is unaffected.

Or pass the key explicitly:

import { createAuditLogger } from "@agentlair/audit-logger";

const log = createAuditLogger("my-agent", {
  transport: "agentlair",
  agentlairApiKey: process.env.AGENTLAIR_API_KEY,
});

await log({ action: "tool_call", tool: "web_search", input: "latest AI news" });

Get a free API key at agentlair.dev.


API

auditLog(entry, opts?) — module-level convenience

The simplest way to log. Uses a shared module-level logger instance.

await auditLog({
  agent: string;        // Name/ID of the agent
  action: string;       // Action category (e.g. "tool_call", "llm_response", "decision")
  tool?: string;        // Tool name, if this is a tool call
  input?: unknown;      // Input to the tool or LLM
  output?: unknown;     // Output from the tool or LLM
  timestamp?: string;   // ISO 8601 — defaults to now
  metadata?: Record<string, unknown>;  // Any additional context
});

AuditLogger class

For AAR split (beginAction/endAction) and HMAC signing, instantiate directly:

const logger = new AuditLogger({
  actorId: string;        // Agent identity (used as 'sub' in receipts)
  hmacSecret?: string;    // Optional: HMAC-SHA256 key for receipt signing
  apiKey?: string;        // Optional: AgentLair API key (or set AGENTLAIR_API_KEY)
  silent?: boolean;       // Optional: suppress transport (testing)
});

logger.beginAction(opts)Promise<AARPreAction>

Emits a signed, chained pre-action receipt. Call before tool execution.

FieldTypeRequiredDescription
toolNamestringName of the tool being called
toolCallIdstringFramework-assigned call ID
inputunknownInput (SHA-256 digested, not stored raw)
approvalDecision'approved' | 'denied' | 'conditional'From preflight_trust_check
policyRefstringLink to the trust check result ID
decidedBystringIdentity who approved (for human-gated tools)
sessionIdstringSession context
expiresAtstring | DateISO 8601 deadline (v0.4). Covered by previousReceiptHash.

logger.endAction(opts)Promise<AARTerminalReceipt>

Seals the attempt with a terminal receipt. Call exactly once per beginAction.

FieldTypeRequiredDescription
preActionAARPreActionThe receipt returned by beginAction
phase'executed' | 'failed' | 'denied' | 'expired' | 'cancelled'Defaults to 'executed'. See Terminal phase enum.
startedAtDateWhen execution began. Omit for denied / expired / cancelled.
endedAtDateWhen execution ended. Sign-time check: executed throws if endedAt > expiresAt.
terminalAtDateObservation time of attempt close. Defaults to now. For expired, this is the logger’s noticed-at, not the policy deadline.
terminalReasonstringHuman-readable explanation ('policy_deadline', 'user_cancel', …).
outputunknownTool output (SHA-256 digested). Only for executed.
errorErrorError from failed execution. Only for failed.

Returns: AARTerminalReceipt with resultDigest (executed), errorClass / errorDigest (failed), previousReceiptHash linking back to the pre-action, and a signed terminalAt timestamp.

Transports

TransportWhen usedConfig
"console" (default)Always — great for dev and log aggregators
"file"Append to a local filefilePath: "./audit.log"
"agentlair"Ship to AgentLair for governed audit trailagentlairApiKey or AGENTLAIR_API_KEY
"silent"Testing

Auto-detection: if AGENTLAIR_API_KEY is set, the default transport becomes "agentlair". Otherwise, "console".


Framework adapters

LangChain.js

Drop AgentAuditCallback into any chain, agent, or model’s callbacks array — no manual auditLog() calls needed.

import { AgentAuditCallback } from "@agentlair/audit-logger/langchain";
import { LLMChain } from "langchain/chains";
import { ChatOpenAI } from "langchain/chat_models/openai";

const llm = new ChatOpenAI();
const chain = new LLMChain({
  llm,
  prompt,
  callbacks: [new AgentAuditCallback("my-langchain-agent")],
});

await chain.call({ question: "What is 2+2?" });
// Automatically logs: llm_start, llm_end, tool_start, tool_end, chain_start, chain_end

With AgentLair backend:

new AgentAuditCallback("my-agent", { transport: "agentlair" })

Anthropic / Claude SDK

Wrap the Anthropic client to log every request and response automatically.

import Anthropic from "@anthropic-ai/sdk";
import { wrapAnthropicClient } from "@agentlair/audit-logger/anthropic";

const client = wrapAnthropicClient(new Anthropic(), "researcher");

// Use exactly like the normal client — all calls are logged
const msg = await client.messages.create({
  model: "claude-3-5-sonnet-20241022",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Explain AgentLair in one sentence." }],
});

Logged: request params summary + response usage (input/output tokens, stop reason).


File transport example

await auditLog(
  { agent: "batch-processor", action: "process", input: { jobId: "job-42" } },
  { transport: "file", filePath: "/var/log/agents/audit.log" }
);

Appends JSON lines. Compatible with any log aggregator (Datadog, Loki, CloudWatch).


Querying stored audit logs

When AGENTLAIR_API_KEY is set, audit entries are stored as Observations under the audit-log topic in AgentLair. Query them with the AgentLair SDK:

import { AgentLair } from "@agentlair/sdk";

const lair = new AgentLair(process.env.AGENTLAIR_API_KEY!);
const { observations } = await lair.observations.read({
  topic: "audit-log",
  limit: 50,
});

for (const obs of observations) {
  console.log(obs.data); // ResolvedAuditEntry
}

Each stored entry includes the full agent, action, tool, input, output, timestamp, and metadata fields from the original log call.


GitHub

Source code and issues: github.com/piiiico/agentlair


EU AI Act Article 12 Compliance

The EU AI Act (high-risk provisions enforceable December 2, 2027 — delayed from August 2026 via Omnibus deal) mandates automatic, tamper-evident logging for high-risk AI systems under Article 12. AgentLair’s audit trail satisfies these requirements structurally:

Article 12 RequirementAgentLair Implementation
Automatic recordingMiddleware-level — no opt-in required
Tamper-evidenceEd25519-signed entries + SHA-256 hash chain
Independent of AI systemLogging outside agent’s control boundary
Queryable for monitoringFilter by time, category, actor, outcome
6-month minimum retentionStarter tier: 1 year. Enterprise: up to 7 years

When connected to AgentLair (AGENTLAIR_API_KEY set), your agent’s audit trail is:

  • Signed with keys the agent cannot access
  • Chained so insertions/deletions are detectable
  • Retained beyond the regulatory minimum
  • Exportable as JSONL/CSV for regulatory submission

The beginAction / endAction split strengthens this further: each tool call is anchored to the chain before execution begins, making retroactive forgery cryptographically infeasible.

Read more: EU AI Act Article 12: Your Agent’s Logs Are Inside-Out


What’s next

  • Vault — Zero-knowledge secret storage for your agents
  • API Keys — Get your free aal_... API key