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-messageEach hook lives in its own folder:
src/hooks/<name>/
└── index.ts # ImplementationThe implementation module exports a factory:
export function createHook(deps): HookThere 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'sTalkRouter.
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 callnext(). 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 throughctx.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:
| 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:
export interface TurnMiddlewareHookDeps {
appId: string;
logger: AppLogger;
appContext?: RomeAppContext;
agentRunner?: AgentRunnerInterface;
}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 |