Skip to main content
Before you start
  • You can read this only on the machine Postern runs on. From your own computer, forward the port first, then open http://localhost:8787:
  • You need no agent key. Whether you can reach this computer is the whole of the authorization.
Two surfaces read the same rows: The ledger in the Console, and GET /api/audit on the Console port. Both serve the same history, newest first. What one row holds names every column and what it means.

Read it in the Console

Click The ledger in the left nav. The table has six columns: Time, Who, Tool, Sector, Args, Decision. Click any row to open the Ledger entry panel — Time, Agent, Tool, Sector, Decision, Deny reason, Outcome, Result count, Request id and Args (redacted, verbatim). Press Close or Esc to dismiss it. The Decision column shows only refusals and errors. Open a row to see its state in full. There is no filter for errored calls. The API accepts no filter on outcome.
A sector filter is not a narrower view of the whole record. It is an equality match on the sector column, and SQL equality never matches null. Every call that happened in no sector leaves the view — an agent that asks what Postern holds, a refused handshake. Those calls are still recorded. The Console says so under the control the moment you set one.
One page is 100 rows. Load more ↓ walks older pages with the cursor Postern creates. The view can reach the true end of history, and it says so when it does. Export CSV takes the rows currently loaded and nothing else. Its column names match the API’s field names, not the table headers. Values that look like spreadsheet formulas get a leading apostrophe, so they cannot run when you open the file.

Read it over HTTP

GET /api/audit serves the same pages as JSON, on the Console port. Open a terminal on the machine Postern runs on and run:
-s hides the progress meter; -D - prints the response headers, where the paging values live. A working call prints HTTP/1.1 200 OK, the headers, then a JSON array of up to 5 rows, newest first. There is no envelope and no total. 8787 is the Console port on a stock install. REST_PORT in .env moves it and this endpoint follows. .env sits beside docker-compose.yml; edit it there, not in the Console, then run docker compose up -d. From any other computer this call fails, and that is correct. Compose opens the port to this computer only. Postern also refuses any request whose Host header is not localhost, 127.0.0.1 or ::1, with 403 and {"error":"forbidden host"}.

Filters

Every filter is optional, and every value is a bind parameter.
string
Exact match on agent_id.
string
Exact match on tool.
string
allow or deny.
string
Exact match, verbatim. Postern never checks it against the registry, because the record is history: a sector that no longer exists returns its rows rather than an error. Excludes every row whose sector is null.
string
An ISO timestamp. Returns rows at or after it. A value that does not parse is a 400.
string
The cursor for the next page. Send back the previous response’s x-postern-audit-next header exactly as you received it. Never build your own from a row’s created_at. The wire timestamp has lost the microseconds the cursor needs, so a rebuilt cursor silently skips rows. Batch inserts make those neighbours common. A bad value is a 400: invalid 'before' cursor: echo the previous page's x-postern-audit-next header value unmodified.
number
default:"100"
Capped at 1000 without complaint — a larger request returns the ceiling, not an error. Anything that is not a positive whole number (0, -1, abc, 1.5) is a 400 with invalid 'limit'. This bounds one page, not what is reachable.

The served row

Every column of the table, minus user_id: id, agent_id, tool, sector, decision, deny_reason, args, result_count, outcome, request_id, created_at. What one row holds gives each one its meaning. Two differences from the table. id is a bigserial in the database and a string on the wire. created_at is ISO text at millisecond precision, so the cursor’s microseconds are not in it. user_id is never served. There is one user on a self-hosted Postern, and the route scopes every query to them.

Paging headers

  • x-postern-audit-has-moretrue or false on every 200. false means this is the last page. Postern fetches one row past your limit and trims it, so a page that fills exactly still reports false truthfully.
  • x-postern-audit-next — the cursor for the next page, present when has-more is true.
An absent has-more header is not false. Absent means Postern did not answer the question. Treat it as unknown, never as the end of the record.

Confirm it works

  • Console → The ledger shows at least one row, or the empty state Nothing has come through the gate yet. Every read and action will be recorded here.
  • Send a connected agent a message such as “What can you see in Postern?”. With Filter by sector on All sectors, a row appears within about 2 seconds, with the agent’s name under Who and describe_context under Tool.
  • Pause a connection or run a sync. A row appears with operator under Who.
  • Point an agent at Postern with a key it does not know, then restart that agent. The client fails to connect, and a row appears with Denied under Decision.

If something went wrong

Next

The audit log

what one row holds, what gets a row, and what does not

What the record cannot promise

what is dropped before it lands, and what nothing in the database prevents