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.
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 orEsc 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.
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, minususer_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-more—trueorfalseon every 200.falsemeans this is the last page. Postern fetches one row past yourlimitand trims it, so a page that fills exactly still reportsfalsetruthfully.x-postern-audit-next— the cursor for the next page, present when has-more istrue.
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_contextunder Tool. - Pause a connection or run a sync. A row appears with
operatorunder 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