Skip to content

Audit Trail

Pinchy includes a built-in audit trail that logs every significant action on the platform. Each entry is cryptographically signed with HMAC-SHA256 to detect tampering. The audit log is append-only — PostgreSQL triggers prevent any modification or deletion of existing entries.

The audit trail is designed for compliance and security. It answers the question: "Who did what, and when?"

Audit trail showing diverse events from users and agents

Pinchy logs event types across several categories:

| Event Type | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | | tool.<toolName> | An agent executed a tool — event type is dynamic per tool (e.g. tool.pinchy_read, tool.odoo_read, tool.pinchy_web_search) | | tool.denied | An agent attempted to use a tool that was not in its allow-list |

Each tool execution produces one audit entry — logged when the tool call completes (end phase only). The detail includes the tool name, parameters, and result. Start events are received but not persisted.

Every entry in the audit log — not just tool calls — carries a status that shows whether the action succeeded or failed:

  • A green check mark indicates a successful event (a login, an agent update, a tool call that returned cleanly, a config change, etc.).
  • A red X indicates a failure (e.g., a failed login, a denied tool, a tool call that errored out, or any other event marked as a failure).
  • Legacy entries from before this feature was added show a neutral "—" with a "Logged before status tracking" tooltip. The schema did not track success/failure at the time. This is normal and not an integrity issue.

You can filter the audit log by status using the Status dropdown above the table. Filtering applies to every event type, not just tool calls:

  • All Statuses — show every entry.
  • Success only — only successful events. Legacy entries are excluded.
  • Failures only — only failed events. This surfaces auth.failed attempts, denied or errored tool calls, and anything else marked as a failure — useful for spotting recurring problems quickly.

The Event Type filter and Status filter are complementary. Combine them to answer questions like "all failed Auth events in the last 24 hours" or "all failed tool calls for this agent".

When viewing a failed event's detail, the error message is shown prominently above the raw event JSON, so you don't have to dig through the detail blob to find the cause.

The Status column is also included in CSV and PDF exports, alongside an Error column with the failure message. Status filtering applies to exports too.

| Event Type | Description | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | auth.login | A user successfully logged in | | auth.failed | A login attempt failed (wrong password, unknown email) | | auth.logout | A user logged out | | auth.csrf_blocked | A state-changing request was rejected because its Origin / Referer headers didn't match the locked domain. Detail captures method, pathname, origin, referer, and remoteAddress — see Hardening › CSRF gate. | | auth.password_reset_completed | A user finished a password-reset invite. On success, detail snapshots the target {id, name} and the invite id; all of that user's existing sessions are revoked in the same transaction. On failure (rare — e.g. DB error mid-flow), outcome: failure is written with the error message and the entire reset rolls back so the old password and the reset token both remain usable. |

| Event Type | Description | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | agent.created | A new agent was created | | agent.updated | An agent's settings or permissions were changed | | agent.deleted | An agent was deleted | | agent.memory_changed | A file in the agent's durable long-term memory (MEMORY.md or memory/*.md) was created, modified, or deleted | | agent.model_unavailable | The agent's configured model returned an HTTP 5xx (server-side failure or discontinuation). One entry per (agent, model) per 5 minutes — repeated failures inside the window are aggregated to avoid log spam. Detail captures the upstream ref ID. | | agent.upstream_format_error | The upstream provider rejected the request with a known schema/format defect that retry usually clears (currently: Gemini 3 thought_signature dropped on tool-call replay). One entry per (agent, model) per 5 minutes. Detail captures the matched pattern and upstream ref ID so frequency tracking is automatic — see Chat connection states › Upstream format errors. |

OpenClaw maintains durable per-agent long-term memory in ~/.openclaw/agents/<agentId>/MEMORY.md and ~/.openclaw/agents/<agentId>/memory/*.md. Those files are reloaded into the agent's context at every session start, so they directly shape behavior in every future conversation. Memory is written in two ways:

  • Explicitly, when the user or agent decides to remember something.
  • Implicitly, via the pre-compaction memory flush — a silent turn that runs before OpenClaw summarizes a long conversation and asks the agent to save important context.

Pinchy watches these files and emits an audit entry on every change so an auditor can answer "the agent suddenly believes X — when and how did that get into its memory?"

  • actorType: "agent" — the agent whose memory changed.
  • actorId: the agent id.
  • resource: agent:<agentId>.
  • detail:
    • agent: { id, name } — snapshot of the agent at the time of the change.
    • file: the relative path under the agent directory — either "MEMORY.md" or "memory/<file>.md".
    • addedLines: number of lines added compared to the prior snapshot.
    • removedLines: number of lines removed compared to the prior snapshot.
    • byteSize: size of the file in bytes after the change (0 for deletions).

The detail never contains file contents — only counts and the relative filename. Deletions surface as byteSize: 0 and removedLines equal to the previous line count.

User fields throughout this section (user.{id}) carry the user's id only — never name or email — per PII rules. Operators join against the users table when they need a human-readable name.

| Event Type | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | chat.background_run_completed | A chat run finished after the user navigated away from the chat page (a "background run"). Detail records agent.{id,name} and the run's wall-clock durationMs. Used together with the sidebar pulse dot for the per-agent background-runtime feature. | | chat.silent_stream | A streaming run ended without producing any assistant text — typically a cold-path tool call that timed out inside OpenClaw's embedded layer. Throttled per (agent, model) so a degraded provider can't flood the log via user retries. Detail captures agent.{id,name}, model, the originating providerError, and reason: "silent_stream_end". | | chat.agent_error | Umbrella event for every chat error chunk plus the silent-stream timeout. Fires alongside the specialised events (agent.model_unavailable, agent.upstream_format_error, chat.silent_stream), not instead of — so a single SQL query grouped by detail->>'errorClass' aggregates every failure shape including the long tail. Not throttled. Detail captures agent.{id,name}, model, errorClass (one of failover_incomplete_stream / schema_rejection / model_unavailable / transient / provider_config / silent_stream_timeout / unknown), and email-scrubbed providerError truncated to 1024 bytes. Detail also carries retried: true when the umbrella event accompanies a silently-recovered single-shot retry from the OpenClaw dispatch-race wrapper (chat-dispatch-retry.ts) — filter on this to separate self-healing transients from user-visible failures (#355). | | chat.retry_triggered | The user clicked Retry on a chat message after a delivery or run failure. Detail records agent.{id,name}, the sessionKey, and a validated reason (orphan / partial_stream_failure / send_failure). The reason string is validated at the trust boundary so a malicious or buggy client cannot write arbitrary strings into HMAC-signed audit rows. | | chat.run_timed_out | The server-side run watchdog tore down a run whose absolute age exceeded the per-deployment cap (default 15 minutes). Detail records agent.{id,name}, user.{id}, sessionKey, the OpenClaw-correlated runId, the actual elapsedMs, and the configured maxRunDurationMs. Always pairs with a chatAbort call to OpenClaw and a terminal error frame broadcast to any still-connected listener. Actor is system / watchdog. | | chat.run_no_first_chunk | The run watchdog tore down a run that never produced a first chunk (OpenClaw's acceptance acknowledgement) within the first-chunk timeout (default 180 seconds) — the dispatch hung at request-receive and never landed. Distinct from chat.run_timed_out (a run that started streaming but never finished) so analysts can tell "the agent never started responding" apart from "the agent ran long". Detail records agent.{id,name}, user.{id}, sessionKey, the provisional runId, the waitedMs, and the configured firstChunkTimeoutMs. Pairs with a chatAbort and a retryable error frame so the user can resend instead of staring at a blank thread. Actor is system / watchdog. | | chat.run_aborted | The user stopped an in-flight run with the chat composer's stop button. Pinchy calls chatAbort on OpenClaw so the agent stops generating and the session lane is released for the next turn, then records this event. Detail records agent.{id,name}, sessionKey, the runId, and reason: "user_request". outcome is failure when the OpenClaw-side abort could not be delivered (e.g. the gateway was briefly unreachable) — the row is still written so a failed abort is auditable too. Actor is the user. Only emitted while a run is actually streaming: a stop click with nothing in flight still reaches OpenClaw's abort safely but writes no row (nothing to attribute it to). | | chat.run_completed_after_disconnect | An in-flight run finished normally but the originating browser session had already gone away. Pinchy keeps draining the OpenClaw stream after disconnect (so the assistant turn lands in OpenClaw's session JSONL), and this event captures the "your run completed for nobody" case for operator visibility. Detail records agent.{id,name}, user.{id}, sessionKey, and the runId. Fires from the pipeStream finally block when no listeners remain and the stream ended without a terminal error. | | file.upload.attached | A staged file was materialised into a chat message. Logged once per file (see File uploads below). On success the detail records uploadId, messageId, filename, and agent.{id,name}. On failure (cross-user, already-attached, or expired attachment attempt) the detail records uploadId and a reason. The file contents themselves are never written to the audit row. |

Chat attachments are uploaded in two steps. The composer first stages each file via POST /api/agents/<agentId>/uploads, and the chat message later references the staged files by id. Each step writes its own audit event, and an hourly garbage-collection sweep cleans up files that were staged but never attached.

| Event Type | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | file.upload.staged | A file was uploaded to the staging area via POST /api/agents/<agentId>/uploads. Resource is agent:<agentId>. On success the detail records uploadId, filename, mimeType, sizeBytes, contentHash, and agent.{id,name}. On failure (oversize, bad filename, or unsupported MIME) the detail records filename, claimedMime, a reason, and agent.{id,name}. Uploads are capped at 15 MB per file — a larger file is rejected with HTTP 413 and a failure entry. | | file.upload.attached | A staged file was promoted into a chat message. Detail shapes are described in the Chat runtime table above. | | file.upload.expired | The hourly GC sweep removed a staged file that was never attached (24-hour staged TTL). Actor is system / upload-gc. Every row from a single sweep carries the same sweepId correlation UUID so an analyst can pull the whole sweep from one drill-down. On success the detail records uploadId, filename, sizeBytes, agedSeconds, and sweepId. On failure the detail records uploadId, filename, sweepId, and a reason. |

| Event Type | Description | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | user.invited | An admin invited a new user | | user.invite_blocked | An admin attempted to invite a user but the action was refused by a licence/seat-cap guard. Detail records the (redacted) email, the chosen role, the reason (e.g. seat_cap), and the relevant licence counters (seatsUsed, maxUsers). | | user.updated | A user's profile was changed | | user.role_updated | A user's role was changed (e.g. member → admin) | | user.groups_updated | A user's group memberships were changed | | user.deleted | A user account was deleted |

| Event Type | Description | | ----------------------- | --------------------------------------------- | | group.created | A new group was created | | group.updated | A group's settings were changed | | group.deleted | A group was deleted | | group.members_updated | Members were added to or removed from a group |

| Event Type | Description | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | channel.created | An agent was connected to an external channel (e.g. a Telegram bot token was added) | | channel.deleted | An agent was disconnected from a channel (or all channels of one type were removed) | | channel.degraded | The channel-health watchdog detected a channel poller that stopped working — most commonly a Telegram bot whose token is being polled by a second deployment, which makes Telegram return a getUpdates 409 conflict and OpenClaw crash-restart the channel worker. Fires once on the healthy→degraded edge. Detail records channel, account.{id,name} (the agent), the OpenClaw lastError, and reconnectAttempts. Actor is system / channel-watchdog. | | channel.polling_failed | A degraded channel stayed down across several consecutive probes — an escalation of channel.degraded signalling the auto-restart loop is not recovering on its own. Same detail shape, plus consecutiveDegradedChecks. Actor is system / channel-watchdog. | | channel.recovered | A previously degraded channel started polling cleanly again (outcome: success). Pairs with the channel.degraded row to bound the outage window. |

| Event Type | Trigger | Detail fields | | --------------------------------- | ------------------------------------------------------------------------------- | -------------------------- | | integration.created | A new integration connection was added (Odoo, Web Search, Google, …) | type, name | | integration.updated | Name or description of an existing connection changed | id, name, changes | | integration.deleted | A connection was removed | id, name, type | | integration.credentials_updated | Successful PATCH with new credentials or a completed OAuth re-auth | id, name, fields | | integration.synced | Schema was successfully re-synced from the upstream service (Odoo) | id, name, modelCount | | integration.auth_failed | First detection of a permanent auth failure (401/403 from the upstream service) | id, name, reason | | integration.auth_recovered | First successful request after an auth_failed state | id, name |

Credential changes always emit only integration.credentials_updated — even when name or description change in the same PATCH call. The integration.updated event covers metadata-only edits. This split lets CISOs filter the trail for "every credential touch" with a single eventType = predicate, without having to also union over generic *.updated events.

Only the first failure event in a sequence is logged — repeated failures while the integration is already in a failed state do not produce additional entries. Similarly, integration.auth_recovered is logged once when the integration returns to a healthy state. This keeps the audit log focused on state transitions rather than every retry attempt.

| Event Type | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | config.changed | A system-level configuration setting was changed — provider keys, OAuth-app secrets, Smithers defaults, plugin toggles. Not used for integrations. | | settings.updated | The locked domain was changed. Resource is settings:domain. Detail records a changes diff ({ domain: { from, to } }); unlocking the domain writes to: null. | | settings.deleted | A provider was removed from the settings. Resource is settings:provider:<provider>. Detail records the provider name, the provider id, wasDefault, agentCount (how many agents were reassigned to the remaining provider's default model), the migratedAgents list (capped at 10 inline, with migratedAgentsTruncated set when there were more), and newDefault when the removed provider had been the default. |

| Event Type | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | audit.exported | An admin downloaded an audit-trail export (CSV or PDF). The detail records the chosen format, applied filters, and row count — but never the exported data itself. | | diagnostics.exported | An admin exported a chat-diagnostics bundle for a single agent. The detail records agent.{id,name}, the captured scope (anchor turn and included turn range), the bundle's byteSize, the number of droppedTurns, and whether the bundle was truncated to fit the size cap. The chat content itself is sanitized before export and is never written to the audit row. |

Chat messages are not logged in the audit trail. The audit trail records actions and events, not conversation content. Chat messages are stored separately in the conversation history managed by OpenClaw.

Tool parameters and results are automatically sanitized before being stored in the audit log. This prevents accidental exposure of secrets such as API keys, passwords, or tokens.

  • Sensitive field names — Any JSON field whose name contains password, secret, token, apiKey, credential, or similar terms has its value replaced with [REDACTED].
  • Known secret patterns — String values matching known formats (OpenAI keys sk-…, GitHub tokens ghp_…, Slack tokens xoxb-…, Bearer tokens, Telegram bot tokens, Meta access tokens, and others) are replaced with [REDACTED].
  • Environment file content — When a tool reads a file containing lines like SECRET_KEY=value, the value portion is redacted while the key name is preserved.

Sanitization runs at two layers:

  1. Plugin layer — The pinchy-audit OpenClaw plugin redacts sensitive data before sending it to Pinchy over HTTP. Secrets never leave the agent runtime.
  2. API layer — The tool-use endpoint redacts again before writing to the database. This catches anything the plugin layer might have missed.

The redaction rules are built-in and require no configuration.

Email addresses are personal data under GDPR. Because the audit log is HMAC-signed and append-only, a raw email address written into a row would survive every right-to-erasure request — the row cannot be edited, and editing would invalidate its signature.

Pinchy avoids this by never recording plaintext email addresses in detail. Instead, events that need to identify an inviter, login attempt, or invited recipient store two derived fields:

  • emailHash — keyed HMAC-SHA256 of the lowercased+trimmed email, using the same audit_hmac_secret that signs each row. An admin holding a known address can recompute the hash and match against the log; a leaked log on its own does not yield the addresses back.
  • emailPreview — short masked form, e.g. cl…lm@devcraft.academy (first 2 + last 2 characters of the local part, plus the full domain). Enough for a human auditor to recognise an address they already know, not enough for bulk re-identification. For very short local parts (≤4 characters), the preview equals the address — there is nothing useful to mask. The hash still provides one-way protection in that case.

user.deleted events go further: they record only the user's display name. The userId is already in the resource field, and the audit log is not the right place to keep a deactivated user's contact details.

Hash determinism depends on every Pinchy instance using the same audit_hmac_secret. The default behaviour — auto-generate a secret file under /app/secrets/.audit_hmac_secret on first start — produces a different secret per instance. If you run more than one Pinchy instance against a shared Postgres (HA, blue/green, multiple replicas), set AUDIT_HMAC_SECRET explicitly via env var or mount the same secret file on every instance. Without this, the same email address will hash to different values on different instances, and an admin will not be able to look up its history from one place. The same prerequisite applies to row HMAC verification.

Every audit log entry is signed with HMAC-SHA256, and each signature is chained to the one before it, so the log is tamper-evident as a whole — not just row by row:

  1. When an audit event occurs, Pinchy constructs a canonical payload from the entry's fields (timestamp, event type, actor, resource, detail, outcome, and error).
  2. The payload also folds in the previous row's signature, binding each entry to its predecessor. The very first entry — the genesis row — has no predecessor.
  3. The payload is signed with a server-side HMAC secret. The signature is stored in the entry's row_hmac column, and the chain link — the predecessor's signature — in prev_hmac.
  4. The HMAC secret is auto-generated at startup if the AUDIT_HMAC_SECRET environment variable is not set.

Because each signature covers the previous one, tampering is detectable in every form: editing a field, deleting a row from the middle, truncating the tail, or reordering history all break the chain — not just a single-row edit. Integrity verification recomputes each signature and each chain link and flags the first break. Entries written before the chain was introduced keep their original signatures and stay verifiable under them; the chain binds from that point onward.

Admins can verify the integrity of the audit log in two ways:

  1. Navigate to the Audit page in the admin area.
  2. Click the Verify Integrity button.
  3. Pinchy recomputes HMAC signatures for all entries and reports any mismatches.

Send a GET request to /api/audit/verify. Optional fromId and toId parameters let you verify a specific range of entries.

Terminal window
curl -b session_cookie https://your-pinchy-instance/api/audit/verify

The response indicates whether all entries are intact:

{
"valid": true,
"totalChecked": 142,
"invalidIds": []
}

If tampered entries are found, valid is false and invalidIds contains the IDs of the affected rows.

The audit log can be exported in two formats — CSV for data analysis and downstream tooling, PDF for formal reports, archival, and regulatory submissions. Both formats apply the same filters and pass through the same sanitization rules, so secrets are never written to either output.

EU AI Act Article 12 (record-keeping for high-risk AI systems, applicable from 2 August 2026) and most enterprise compliance frameworks require that audit logs can be produced for external review on demand. Both exports include the per-row HMAC signature so an auditor can independently verify that the exported entries match the cryptographic evidence in the database.

Every export — regardless of format — contains:

  • Timestamp (UTC, ISO 8601)
  • Actor — name (snapshotted) and ID, plus type (user, agent, system)
  • Event type — what happened (auth.login, agent.updated, tool.pinchy_read, …)
  • Resource — name (snapshotted) and ID of the affected entity, where applicable
  • Statussuccess or failure (legacy entries before status tracking show empty)
  • Error message — for failed events
  • Detail — the structured event payload, after sensitive-data redaction
  • Integrity hash — the row's HMAC-SHA256 signature, so the export is independently verifiable
  • Schema version — which HMAC version was used to sign the row
  1. Navigate to the Audit page.
  2. Apply any desired filters (date range, event type, status).
  3. Click Export and choose Export as CSV or Export as PDF.

Send a GET request to /api/audit/export with optional filter parameters (eventType, actorId, resource, status, from, to). Use the format parameter to pick the output: csv (default) or pdf.

Terminal window
# CSV export (default)
curl -b session_cookie "https://your-pinchy-instance/api/audit/export?from=2026-01-01&to=2026-02-01" -o audit-log.csv
# PDF export — formal report, suitable for printing and archival
curl -b session_cookie "https://your-pinchy-instance/api/audit/export?format=pdf&from=2026-01-01&to=2026-02-01" -o audit-log.pdf
# Filter to a specific agent's tool calls
curl -b session_cookie "https://your-pinchy-instance/api/audit/export?resource=agent:abc123&eventType=tool.pinchy_read" -o smithers-read-audit.csv

The PDF report contains a header with generation timestamp, the active filters, and the total number of entries, followed by a paginated table of the entries themselves. Each page is footed with a page counter and a reminder that the underlying data is HMAC-SHA256 signed.

The two formats target different audiences:

  • CSV is the complete record. It includes every column, including the structured detail payload (sanitized) for each event. Use CSV for downstream tooling, SIEM ingestion, spreadsheets, scripted analysis, and any case where an auditor wants to recompute the row HMAC themselves.
  • PDF is the printable summary. It shows timestamp, actor, event, resource, status, and the first 16 hex characters of the HMAC for each row — enough to identify and corroborate an entry against the database, but not the full payload. Use PDF for formal reports, signed archival, and submissions to regulators who expect a paginated document.

If a stakeholder needs the full event payload in a printable format, generate the CSV and let them produce a PDF from there with their preferred tooling — the PDF view in Pinchy is intentionally summary-only.

Known limitation: non-Latin characters in PDF

Section titled “Known limitation: non-Latin characters in PDF”

The PDF renderer uses PDFKit's built-in Helvetica font, which covers Latin-1 only. Actor or agent names written in scripts outside Latin-1 (Cyrillic, CJK, Arabic, Devanagari, etc.) will render as boxes or empty space in the PDF output. The CSV export is unaffected — it is UTF-8 throughout. If you need a PDF with non-Latin names, export as CSV and convert on the client side, or open an issue so we can prioritize bundling a Unicode font.

The audit trail uses multiple layers to ensure entries cannot be modified:

  1. PostgreSQL triggersBEFORE UPDATE and BEFORE DELETE triggers on the auditLog table raise an exception, preventing any modification or deletion at the database level.
  2. HMAC signatures — Even if triggers were somehow bypassed, any modification would invalidate the cryptographic signature.
  3. Append-only API — The application code only inserts entries. There is no update or delete endpoint for audit entries.

Audit logging uses a fire-and-forget pattern: if logging fails (e.g., database connection issue), the main operation still succeeds. This ensures that audit logging never degrades the user experience or blocks critical operations.

The trade-off is that in rare failure scenarios, an action might not be logged. For most enterprise deployments, this is preferable to having audit logging cause outages.

Only admins can access the audit trail — both the UI page and the API endpoints. Regular users cannot view, verify, or export audit entries.