OpenStation publishes structured events on an in-process bus and appends them to an
append-only log under var/events/. The log is how you find out what an agent did without
reading a chat transcript.
Reading the log
bun packages/cli/src/index.ts my-space events -n 20
One line per event: timestamp, type, then the fields that identify it. Real output from a turn where the agent filed a note:
2026-07-25T07:00:02.256Z workspace.CommitMade sha=1ff82210023d5c3c77d9220b261442ae859182a8 filesChanged=2 message=agent: workspace update
Filter by exact type:
bun packages/cli/src/index.ts my-space events -t workspace.CommitMade
An empty log prints no events recorded yet.
What is and isn't recorded
Every turn publishes, chat included. turn.TurnStarted and turn.TurnFinished come from
the Bridge on the request path as well as from the scheduler and the trigger dispatcher, so a
conversation leaves a trace even when it changes nothing. That matters most for a silent: true
space: it spends money on every message and, having nothing to say and possibly nothing to
commit, used to leave no evidence anywhere.
A turn the runner refused (at_capacity, conversation_busy) is still reported finished, with
the refusal as its stoppedReason — otherwise "no event" would mean both "never happened" and
"turned away". A message that never became a turn publishes nothing, because no turn existed:
an unauthorized sender, a space whose policy does not admit it, a bridge.reset that dropped
the session ref, or a message a bridge.inbound stage answered itself. A stage that failed
is the exception — see bridge.StageFailed.
What you will actually see, by source:
| Source | Events |
|---|---|
| The Bridge (a chat turn) | turn.TurnStarted / turn.TurnFinished |
| A turn that changed the workspace | workspace.CommitMade or workspace.CommitVetoed |
| The file watcher | workspace.FileChanged |
| The trigger dispatcher | trigger.TriggerFired, plus turn.TurnStarted / turn.TurnFinished |
| The scheduler | turn.TurnStarted / turn.TurnFinished |
| The approval gate | approval.ApprovalRequested, approval.ApprovalResolved |
A bridge.inbound stage that failed |
bridge.StageFailed |
Event types
Every event carries source (who produced it) and ts (an ISO timestamp, stamped by the
producer). The fields below are in addition to those.
turn.TurnStarted
A turn was handed to the TurnRunner.
| Field | Type |
|---|---|
scope |
string — the conversation scope key |
agent |
string |
turn.TurnFinished
A turn returned.
| Field | Type |
|---|---|
scope |
string |
agent |
string |
ok |
boolean |
stoppedReason |
string, optional |
durationMs |
number |
workspace.FileChanged
A file in the workspace's artifacts zone changed — what the trigger dispatcher watches.
| Field | Type |
|---|---|
path |
string |
changeType |
created | modified | deleted |
artifactType |
string, optional — the artifact's declared kind |
artifactType is the type: field of the file's YAML frontmatter, which
OKF requires on every artifact. The
watcher reads a capped head of the file (4 KB, markdown only) at the moment it sees the change,
so the event says what changed and not only where:
2026-07-26T09:12:44.101Z workspace.FileChanged path=okf/issues/b2c2-heartbeat.md changeType=modified artifactType=Issue
It is absent, not empty, in four cases: a deletion (nothing left to read), a non-markdown
file, markdown with no frontmatter, and an unreadable file. That absence is load-bearing — an OKF
index.md or log.md carries no frontmatter, so it can never match a trigger that names an
artifactType:, which is how a rule watches real artifacts without listing the store's own
bookkeeping files. See openstation.yaml.
trigger.TriggerFired
A triggers: rule matched an event and dispatched a turn.
| Field | Type |
|---|---|
on |
string — the rule's subscription |
agent |
string |
matchedType |
string — the event type that matched |
workspace.CommitMade
The agent's work was committed.
| Field | Type |
|---|---|
sha |
string |
filesChanged |
number |
message |
string |
workspace.CommitVetoed
Validation refused a commit. Recorded so a veto is auditable — the commit that did not happen is often what you need to see.
| Field | Type |
|---|---|
reason |
string |
filesChanged |
number |
approval.ApprovalRequested
A turn was held for a human decision.
| Field | Type |
|---|---|
id |
string |
scope |
string |
reason |
string |
approval.ApprovalResolved
A held turn was approved, denied, or expired.
| Field | Type |
|---|---|
id |
string |
scope |
string |
status |
approved | denied | expired |
bridge.StageFailed
A declared bridge.inbound
stage produced no usable result. The message still becomes a turn — this event is what keeps a
broken stage distinguishable from an agent that ignores people.
| Field | Type |
|---|---|
stage |
string — the stage's run: as declared |
scope |
string — the conversation the message arrived in, matching turn.*'s scope |
reason |
exit | unparseable | timeout | spawn |
detail |
string, optional — the child's own diagnostic, capped at 300 characters |
2026-07-30T21:14:02.884Z bridge.StageFailed stage=openbook ingest --stage scope=telegram:-1001234567890 reason=spawn detail=Executable not found in $PATH: "openbook"
stage is the command as declared, never the argv it ran with — that carries the paths of
files someone sent. reason=exit is the one that does not mean the stage was skipped: a
non-zero exit is advisory, so the stage is reported and whatever it printed is honoured.
There is deliberately no bridge.StageRan. Stage-level tracing is observability work with no
consumer, and a per-photo event on a holiday dump is noise.
On-disk format
JSONL, one file per UTC day:
var/events/YYYY-MM-DD.jsonl
Events that name an agent (turns and triggers) are also mirrored into that agent's own diary, one directory per agent:
var/events/agents/<agent>/YYYY-MM-DD.jsonl
The shared stream stays complete either way — the mirror is a per-agent view for tailing, not a partition.
One JSON object per line, matching the schemas above. Lines are schema-validated on the way back out, so a hand-edited file will be rejected on read rather than silently misparsed.
var/ is platform state and belongs outside git — the scaffolded .gitignore excludes it, and
commit automation never stages it even when it isn't ignored.
Appends are fire-and-forget by contract: a log write failure must never break a turn. A failed
append prints [event-log] append failed for <type>: and the turn continues, so the log is an
audit aid rather than a guaranteed-complete ledger.
var/logs/ is the application log — what the process did, written by dev and serve
(shared openstation.log, plus one file per agent under var/logs/agents/). This event log
records what happened to the workspace. See cli.md for openstation logs.