# Hooks
URL: /docs/building-apps/capabilities/hooks
Markdown: /docs/building-apps/capabilities/hooks.md
Description: Register callbacks on the daemon's runtime to extend its message and turn loops.

A **hook** is a callback registered on the daemon's runtime. Hooks are listed in
`app.yaml` and live in their own folders under `src/`.

This page covers the hook interfaces exported by `@rome-os/app-runtime`. For
plain-language guides an agent loads on demand, see [Skills](/docs/building-apps/capabilities/skills).

```yaml
hooks:
  - hooks/channel-message
```

Each hook lives in its own folder:

```
src/hooks/<name>/
└── index.ts       # Implementation
```

The implementation module exports a factory:

```ts
export function createHook(deps): Hook
```

There is no `hook.yaml` — the folder name you declare in `app.yaml` is what
decides the hook's kind (`hooks/channel-message`, `hooks/agent-turn-started`,
`hooks/agent-turn-finished`, `hooks/turn-middleware`), and with it which
interface the returned object must match. The interfaces below are exported
from `@rome-os/app-runtime`.

## The channel-message hook [#the-channel-message-hook]

The main hook is `channel-message`: every incoming channel message passes
through it, and the hook decides what to do with it (for example, whether to
send the sentinel agent at an untrusted sender).

A channel-message hook matches `ChannelMessageHook`:

```ts
export interface ChannelMessageHook {
  register(): void;
  registerConnection(connectionId: string): void;
}
```

* `register()` wires the hook into the daemon's message loop.
* `registerConnection(connectionId)` subscribes to one messaging connection
  through the daemon's `TalkRouter`.

Messages arrive in a single shape, `NormalizedMessage`, the same across every
channel:

```ts
export interface NormalizedMessage {
  id: string;
  channel: "telegram" | "telegram_user" | "whatsapp" | "wechat"
    | "webchat" | "discord" | "email" | "feishu";
  channelUserId: string;
  displayName: string;
  threadId: string;
  threadName?: string;
  threadType: "private" | "group";
  timestamp: Date;
  text: string;
  attachments: Attachment[];
  replyTo?: {
    messageId: string;
    content?: string;
    senderName?: string;
  };
  routing?: MessageRouting;
  rawEvent: unknown;
}
```

The `Talk` interface is the provider-neutral messaging contract: `subscribe`
for inbound messages, `send` for outbound messages, and `feature(name)` for
optional semantic capabilities such as history, inbound media, activity,
directory, and interactions. Transport lifecycle remains owned by the
connection registry. Apps address a specific connection through `TalkRouter`
instead of holding provider transports.

## Agent turn lifecycle hooks (RFC 017) [#agent-turn-lifecycle-hooks-rfc-017]

Two hooks watch the start and end of an agent turn. They are **fire-and-forget
watchers**: the daemon calls them at the turn's edge but does not wait on them
or route around them. Each is a single-method interface.

```ts
export interface AgentTurnStartedHook {
  onAgentTurnStarted(event: AgentTurnStartedEvent): Promise<void> | void;
}

export interface AgentTurnFinishedHook {
  onAgentTurnFinished(event: AgentTurnFinishedEvent): Promise<void> | void;
}
```

The started event gives the turn reference and the input size:

```ts
export interface AgentTurnStartedEvent {
  type: "agent-turn-started";
  version: 1;
  turn: AgentTurnRef;                       // sessionId, turnId, agentName, threadContext, …
  timing: { startedAt: string };
  input: { promptLength: number; attachmentsCount?: number };
}
```

The finished event adds the result, timing, and per-turn metrics:

```ts
export interface AgentTurnFinishedEvent {
  type: "agent-turn-finished";
  version: 1;
  turn: AgentTurnRef;
  status: "completed" | "interrupted" | "stopped" | "error";
  timing: { startedAt: string; finishedAt: string; durationMs: number };
  output: AgentTurnOutput;                  // text, state, terminalKind, accounting, …
  metrics: { toolCallCount: number; skillWritten: boolean };
}
```

Lifecycle hooks receive `AgentLifecycleHookDeps`:

```ts
export interface AgentLifecycleHookDeps {
  appId: string;
  logger: AppLogger;
  appContext?: RomeAppContext;
  agentRunner?: AgentRunnerInterface;
}
```

Use these to record, log, or react to turns on the side — they cannot change
the turn's output.

## Turn middleware hooks (RFC 034) [#turn-middleware-hooks-rfc-034]

Unlike the lifecycle watchers, a **turn middleware** is `await`ed and wraps the
work of making a turn's reply. The middlewares nest like layers of an onion:
each one can rewrite the input and continue, do more work after `next()`, or
stop early and drive the whole turn itself by sending its own events — without
the model ever being called. The innermost layer, which calls the real model, is
fixed in place and is reached only when every middleware calls `next()`.

```ts
export interface TurnMiddlewareHook {
  /** Explicit ordering, lowest runs furthest out. The terminal model layer
   *  is always innermost regardless of declared order. */
  order: number;
  /** How the chain treats a throw from this middleware. `fail-open` (default)
   *  skips it and continues; `fail-closed` aborts the turn with an error block. */
  onError?: "fail-open" | "fail-closed";
  handle(ctx: TurnMiddlewareContext, next: TurnMiddlewareNext): Promise<void>;
}
```

The `handle` method gets the turn context and a `next` continuation:

```ts
export type TurnMiddlewareNext = () => Promise<void>;

export interface TurnMiddlewareContext {
  input: TurnMiddlewareInput;       // { prompt, reasoningEffort? } — prompt is mutable
  session: TurnMiddlewareSession;   // { id, agentName, channelThreadKey }
  emit(event: AgentMessage): void;  // push one synthetic event onto the stream
  meta: { synthetic?: boolean };
}
```

Three patterns come out of `handle`:

* **Rewrite then continue** — change `ctx.input.prompt` (to add context or
  remove text), then call `next()`. The innermost model layer reads the changed
  value.
* **Post-process** — call `next()`, then act on what the inner layers produced.
* **Stop early** — never call `next()`; instead drive the turn yourself by
  pushing events through `ctx.emit`.

There is no declarative matching: every registered turn middleware runs on
every turn of every agent. Filter yourself on `ctx.session.agentName` and call
`next()` right away for turns you don't handle:

```ts
if (ctx.session.agentName !== AGENT_NAME) return next();
```

The nesting is set by `order`: the lowest number runs furthest out.

### Events a middleware emits [#events-a-middleware-emits]

Events pushed via `ctx.emit` must have **the same shape as what a model turn
produces** — the webchat SSE/saving pipeline just reads what comes down and
cannot tell a scripted turn from a model turn. The start/end events
(`turn_start` / `turn_end`) and status are sent by the AgentSession wrapper; a
middleware sends only **content** events. The `AgentMessage` union lists every
event type:

| Category  | Types                                                                                 |
| --------- | ------------------------------------------------------------------------------------- |
| Lifecycle | `turn_start`, `turn_end`, `session_init`                                              |
| Content   | `thinking`, `text`, `tool_use`, `tool_result`, `structured_output`, `result`, `error` |
| Transient | `text_delta` (live preview, never saved)                                              |

Sending a final `result` block is what creates the saved assistant row — so a
stop-early middleware that drives the whole turn must send a `result` (or
`error`) to end it.

Turn middleware hooks get `TurnMiddlewareHookDeps`, the same dependency shape as
the lifecycle hooks:

```ts
export interface TurnMiddlewareHookDeps {
  appId: string;
  logger: AppLogger;
  appContext?: RomeAppContext;
  agentRunner?: AgentRunnerInterface;
}
```

## Choosing a hook [#choosing-a-hook]

| Need                                                   | Hook                                             |
| ------------------------------------------------------ | ------------------------------------------------ |
| React to every inbound channel message                 | `ChannelMessageHook` (`channel-message`)         |
| Watch turn start/finish on the side (logging, metrics) | `AgentTurnStartedHook` / `AgentTurnFinishedHook` |
| Rewrite, wrap, or script a turn's reply                | `TurnMiddlewareHook`                             |