pithy add auditNo prerequisites, no secrets, no account. It is one of the few capabilities in the kit that composes on its own.
What lands in your repo
apps/<worker>/pithy.config.ts gains the registration:
audit({
database: "DB",
basePath: "/audit",
}),apps/<worker>/wrangler.jsonc gains one binding, in every environment stanza: the DB it writes to. If your project already composes anything else, that binding is already there and nothing changes.
What runs
One migration, creating one table: pithy_audit_events.
It is one Zod object — the whole table, with every field described. The app shape and the SQLite row shape are derived from the same definition, and every conversion between them goes through a codec.
The two config options
| Option | Default | What it decides |
|---|---|---|
database | DB | The D1 binding the table and its migrations target. KV is not an option — an audit log is a query workload and KV is get-by-key only |
basePath | /audit | Where the control-plane routes that read the trail mount. Reads only: the trail is append-only and this surface never writes |
Point database at a separate binding if you want the trail in its own D1. Two Workers that both declare that binding still share one database, which is what lets a multi-Worker project keep one trail.
The run that records nothing
The first pithy add audit emits no capability/added event, and cannot: nothing can audit-log its own installation. Every pithy add after it does — at info severity, on success and on failure alike, when Cloudflare credentials resolve.
Nothing else has to change
This is the part worth stating, because it is unusual. No capability needs rewiring, and no route needs editing.
The event contract lives in @pithy-sh/core, so every capability already calls c.var.emit(...). Before you composed audit, that call resolved to a no-op recorder. After, it resolves to this package. The capabilities that were already emitting start writing rows immediately:
| Capability | Records |
|---|---|
auth | Sign-in, token refresh, device revocation |
secrets | Every set, rotation and removal — naming the secret and the environments reached, never the value |
payments | Entitlement grants and revocations |
| The CLI | Capability installs and removals, deploys, schema resets, token mints, feature teardowns |
The consequence: composing audit late gives you a trail that starts today. There is no back-fill, because there is nothing to back-fill from.
Emitting your own events
One call, from any route:
await c.var.emit({
action: "app/report_exported",
outcome: "success",
actorType: "user",
actorId: c.var.auth.userId,
tenant: organizationId,
});action is namespaced by convention — your own capability’s name as the domain, so your events and the kit’s cannot collide. outcome is recorded truthfully on failure too: an attempt that was refused is often the row you most want.
The project, environment and Worker are stamped for you by the recorder, from that Worker’s own vars. You cannot set them, which is what makes them trustworthy.
Reading the trail
Two ways, and they are for different people.
SQL. The table is in your database, so it joins against your own tables with no export step. A staff member’s id resolves to a name because your users table is in the same query.
The control-plane surface. Two scopes — read the events, and read one event’s detail — both default-denied until a management client is connected and granted them. Reading an audit trail is itself a privileged act, and it is audited: a management client walking your event log leaves rows saying so.
Check it worked
pithy doctor reports audit under the Worker’s health. Sign in locally and the first row appears — pithy dev, then a query against pithy_audit_events, is the whole verification.