Hooks

Register callbacks on the daemon's runtime to extend its message and turn loops.

Agent-readable version: Markdown · llms.txt

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.

hooks:
  - hooks/channel-message

Each hook lives in its own folder:

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

The implementation module exports a factory:

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 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:

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:

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)

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.

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:

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:

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:

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)

Unlike the lifecycle watchers, a turn middleware is awaited 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().

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:

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:

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 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:

CategoryTypes
Lifecycleturn_start, turn_end, session_init
Contentthinking, text, tool_use, tool_result, structured_output, result, error
Transienttext_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:

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

Choosing a hook

NeedHook
React to every inbound channel messageChannelMessageHook (channel-message)
Watch turn start/finish on the side (logging, metrics)AgentTurnStartedHook / AgentTurnFinishedHook
Rewrite, wrap, or script a turn's replyTurnMiddlewareHook

On this page