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.
| Receipt | Phase | What it proves |
|---|---|---|
AARPreAction | Before execution | Input digest, authorization decision, deadline, chain position |
AARTerminalReceipt | Attempt closed | Terminal 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:
| Phase | Meaning |
|---|---|
executed | Tool ran to completion. resultDigest carries the output hash. |
failed | Tool ran but threw. errorClass / errorDigest carry the failure. |
denied | Policy refused before execution. No executionStartedAt. |
expired | Authority window elapsed before completion. terminalAt >= expiresAt. |
cancelled | Caller withdrew the attempt before completion. |
Sign-time invariants enforced at endAction():
phase: 'executed'overapprovalDecision: 'denied'throws. Executed-over-denied is structurally impossible to mint.phase: 'executed'whoseexecutionEndedAt > preAction.expiresAtthrows (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.
| Field | Type | Required | Description |
|---|---|---|---|
toolName | string | ✓ | Name of the tool being called |
toolCallId | string | ✓ | Framework-assigned call ID |
input | unknown | ✓ | Input (SHA-256 digested, not stored raw) |
approvalDecision | 'approved' | 'denied' | 'conditional' | — | From preflight_trust_check |
policyRef | string | — | Link to the trust check result ID |
decidedBy | string | — | Identity who approved (for human-gated tools) |
sessionId | string | — | Session context |
expiresAt | string | Date | — | ISO 8601 deadline (v0.4). Covered by previousReceiptHash. |
logger.endAction(opts) → Promise<AARTerminalReceipt>
Seals the attempt with a terminal receipt. Call exactly once per beginAction.
| Field | Type | Required | Description |
|---|---|---|---|
preAction | AARPreAction | ✓ | The receipt returned by beginAction |
phase | 'executed' | 'failed' | 'denied' | 'expired' | 'cancelled' | — | Defaults to 'executed'. See Terminal phase enum. |
startedAt | Date | — | When execution began. Omit for denied / expired / cancelled. |
endedAt | Date | — | When execution ended. Sign-time check: executed throws if endedAt > expiresAt. |
terminalAt | Date | — | Observation time of attempt close. Defaults to now. For expired, this is the logger’s noticed-at, not the policy deadline. |
terminalReason | string | — | Human-readable explanation ('policy_deadline', 'user_cancel', …). |
output | unknown | — | Tool output (SHA-256 digested). Only for executed. |
error | Error | — | Error 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
| Transport | When used | Config |
|---|---|---|
"console" (default) | Always — great for dev and log aggregators | — |
"file" | Append to a local file | filePath: "./audit.log" |
"agentlair" | Ship to AgentLair for governed audit trail | agentlairApiKey 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 Requirement | AgentLair Implementation |
|---|---|
| Automatic recording | Middleware-level — no opt-in required |
| Tamper-evidence | Ed25519-signed entries + SHA-256 hash chain |
| Independent of AI system | Logging outside agent’s control boundary |
| Queryable for monitoring | Filter by time, category, actor, outcome |
| 6-month minimum retention | Starter 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