---
{
  "n": 18,
  "title": "Handlers take a context and typed events",
  "abstract": "Design for comment: every handler receives a context and a typed event; events are named <thing>.<past-tense verb>; the catch-all agent.updated splits into one event per hook",
  "refs": [
    "todo:584",
    "doc:38"
  ],
  "seen": [
    "agent"
  ],
  "data": {
    "revisions": 1,
    "status": "final"
  },
  "created": 1790019625.0,
  "updated": 1790120731.7397199,
  "deleted": 0.0,
  "completed": 1790120731.608865,
  "outcome": "replaced by doc 38, The journal core, its features, and how they talk; restored 2026-09-23 from the commands that wrote it, after its file was found empty",
  "type": "doc"
}
---
Asked in message 1311, shaped by questions 45-48 (context, snake_case, one change, agent messages stay events) and message 1325 (a design to comment on before any plan). Nothing is built.

## 1. What changes, in one picture
Today a handler is a method on the feature that receives the raw event and the record, and digs out what it needs:

    @event("agent.said")
    def said(self, event, record) -> None:
        agent, text = self.agent(event, record), str(event.data.get("text") or "")
        if agent and text.strip():
            self.check(record, agent, text)

After this change it is a class of its own that receives a context and a typed event, and reads like its intent:

    class RemindToTag(Handler):
        def handle(self, context: Context, event: AgentMessageCreated) -> None:
            if not has_tag(event.text):
                context.agent.say("untagged")

The feature registers it: journal.events.handler(RemindToTag()). The event comes from the type of the event parameter, so feature code has no event string and no decorator (question 49).

Four things make that possible: the context object (section 2), typed events (section 3), one naming scheme for events (section 4), and features that only register their parts (section 5a).

## 2. The context
Every handler receives one object, context, built by the bus for the environment and the agent the event concerns. It carries:
- context.record: the environment's record, the same object handlers pass around today.
- context.settings: the feature's own settings for this environment (today self.setting(record, ...)).
- context.agent: the agent the event is about, or None. It speaks for itself: context.agent.say(line, **values), .whisper(...), .type(...) - the messenger's say/whisper/type with the record and the agent already filled in.
- context.journal: the messenger for everything not addressed to one agent: context.journal.notify(line), .notice(line), .log(line), .clear(row).
- context.due(behaviour=""): the feature's cadence check (today self.due(record, agent, key)).

A feature stops threading record and agent through its methods; they read what they need from context. The feature's lines stay declared on the feature, as now.

## 3. Typed events
Each event is a small frozen dataclass with named fields, in one module (engine/events.py):

    @dataclass(frozen=True)
    class AgentMessageCreated(Event):
        agent: int      # agent row number
        text: str

    @dataclass(frozen=True)
    class TodoCompleted(Event):
        todo: int
        how: str

A handler subscribes through the type of its event parameter: journal.events.handler() reads the annotation on handle(), so there is no event string in feature code. The class also names the event on the bus and in the log (AgentMessageCreated is agent.message.created). Resource events keep their row number and the fields the save already records. Events a plugin or the viewer reads stay plain JSON on the wire; the classes are how Python sees them.

## 4. Event names
One scheme everywhere: <thing>.<past-tense verb>, and the class name says the same (TodoCompleted is todo.completed).

Resource events already follow it and stay: todo.created, message.updated, plan.linked, work.completed and so on.

The ones that change:
- agent.said becomes agent.message.created (AgentMessageCreated). Agent messages stay events; the transcript is their record (question 48).
- agent.updated, which 33 handlers listen to and then check which hook fired, splits into one event per hook:
  - agent.session.started (SessionStart; its source says new, resumed or compacted)
  - agent.prompt.submitted (UserPromptSubmit)
  - agent.tool.started and agent.tool.finished (PreToolUse and PostToolUse)
  - agent.turn.stopped (Stop)
  - agent.context.compacting (PreCompact)
  - agent.session.ended (SessionEnd)
  - agent.permission.requested (a permission prompt)
  A handler that truly cares about any change to the agent row still has agent.updated.
- The broad subscriptions (every event, every creation, every change to one type) stay, through a broader type: annotating the event as ResourceCreated catches every creation, as Event catches everything. Only start, cleanup and housekeeping need them.

## 5. Handlers and names
- Methods are snake_case and named for what they do: remind_to_tag, greet_new_session, file_failed_check, not check or sweep. Everything in Python is snake_case (question 46, message 1339); classes are CamelCase, as Python writes them.
- A handler does one thing. The 33 agent.updated handlers mostly become a handler on the one hook they actually care about, which removes the if agent.event == ... branches at their top.
- Handlers, text formatters and tool interceptors are parts that a feature registers; section 5a says how.

## 6. Rollout
One change, one release (question 47):
- the bus builds the context and dispatches typed events;
- every feature (about 35) moves to (context, event) handlers and the new names;
- the engine emits the per-hook agent events and agent.message.created;
- the generated skills list events by their new names;
- tests move with them, and the worktree test project is run end to end before release.
The old (event, record) form is removed in the same change, so there is only ever one way to write a handler.

## 7. Open questions for you
1. Plugins: installed plugins subscribe by event name (for example todo.created, agent.updated). Keep the old names as aliases for plugins for one release, or rename for plugins too at once?
2. agent.updated split: is the list in section 4 the right granularity, or do you want fewer (for example just session, tool, turn)?
3. Should context.agent be None for events not about an agent, or should such handlers not receive an agent at all (a smaller context type)?
4. Interceptors (the write gate): move them to the same (context, event) shape now, or in a later change?

## 5a. A feature only registers its parts
Message 1338. A feature class holds no logic of its own. Every handler, text formatter and tool interceptor is its own small class, holding its own logic, and the feature hands each one to the journal explicitly, in one place. The @formats and @interceptor decorators go away, and so do the logic methods on feature classes.

Tags, as it would read:

    class Tags(Feature):
        details = TagsDetails

        def register(self, journal: Journal) -> None:
            journal.events.handler(RemindToTag())
            journal.events.handler(RunTagCommand())
            journal.client.formatter(StripTags())
            journal.agent.interceptor(NotifyTagNotUsed())

    class StripTags(TextFormatter):
        def format(self, context: Context, text: str) -> str:
            return visible(text)

    class NotifyTagNotUsed(ToolInterceptor):
        def intercept(self, context: Context, call: ToolCall) -> str:
            ...

    class RemindToTag(Handler):
        def handle(self, context: Context, event: AgentMessageCreated) -> None:
            if not has_tag(event.text):
                context.agent.say("untagged")

What describes the feature (name, title, abstract, help, lines and behaviours) lives in its details.py, as one FeatureDetails class; the feature class says details = TagsDetails and keeps only register() and its settings view (message 1342). The parts live beside it: features/tags/handlers.py, formatters.py, interceptors.py, and reading.py for the settings readers they share.

journal has three registries, with short snake_case names (question 50):
- journal.events.handler(...): a handler's event is read from the type of its event parameter, so there is no event string.
- journal.client.formatter(...): formatters run on every text a person reads (rule 29).
- journal.agent.interceptor(...): interceptors run on the agent's tool calls, before the tool runs.

register() runs once, when the feature loads. A feature switched off in an environment stays registered; the journal skips its parts in that environment, as it does today.

Handlers are classes like every other part (question 49): @event goes away with @formats and @interceptor.
EOF
)

## 5b. Sending to the chat is core, not a feature
Message 1350. What is plumbing lives in the core; a feature is behaviour on top of it. Putting the agent's words in the chat is plumbing, so no feature does it and no feature creates a Messages row just to show text.

The journal client (the backend side of the viewer) owns the chat:

    agent text arrives (MessageDisplay hook, transcript, Stop hook)
      -> journal.client receives it
      -> dispatches AgentMessageSending(agent, text): listeners may read and change it
           Tags     strips the tags and runs [!reply:n], [!log:n] ...
           Messages saves it as a message row, so it can be replied to and read later
      -> journal.client sends the result to the viewer over its stream (GET /api/{env}/stream)
      -> dispatches AgentMessageSent

The client knows nothing about the messages resource; the messages feature is one listener among others. A listener can change the text, or stop the send (a reply tag's turn is shown as the reply, so Tags stops the plain copy.

Tags' CopyToChat goes. Everything the agent writes reaches the chat because the client sends it, not because a feature copies it.

To-do 590. Open question 52: whether the chat is drawn from what the client pushes, or still from the stored rows.
