Building
Middleware hooks
The 14 points where a plugin can rewrite or veto what an agent is doing, as it happens.
Middleware runs inside an agent's work, at a fixed point, and can change the value passing through it — the system prompt, an incoming message, a message between two agents, a todo, a timer, a shell command about to run on a server, a scheduled mission about to fire. Most hooks can also veto the operation.
This is how the official Caveman plugin makes every answer terse, and how a plugin can redact secrets from SSH
output, block rm -rf /, audit every message between agents, or hold scheduled missions during a maintenance
window.
The shape of a hook
globalThis.middleware.<hook> = async (context, tools) => {
// read context, maybe call host.*
return newValue; // or: return tools.cancel("why");
};
context— what is happening, as plain JSON. Ids are strings (they are 64-bit numbers that JavaScript can't hold exactly).tools.cancel(reason?)— returns a veto token. Return it to stop the operation. The reason (≤ 500 characters) is recorded.- What to return depends on the hook's mutable fields (the table below):
- One field — return the new value itself (a string for
text, astring[]fortexts). - Several fields — return an object with the fields you change; omitted fields keep their value.
- No fields — observe-only; the return value is ignored.
- One field — return the new value itself (a string for
Return the unchanged value to pass through: return context.text. Returning undefined from a hook that has a
field to fill is treated as a malformed result: your handler is skipped and the original value is kept.
Rules that apply to every hook
- Account-wide. Once installed, your hooks run for every agent on the account.
- Ordered chain. When several plugins hook the same point they run one after another; each sees the previous one's output. A cancel stops the chain.
- Fail-open. If your handler throws, times out (5 s), or returns the wrong shape, it is skipped with a warning and the value continues unchanged. A broken middleware can't take an agent down.
- 5 seconds per handler. Middleware sits on the agent's critical path — keep it fast.
- Full host surface. A hook gets the same
hostmembers a tool with the same permissions would — includinghost.session(the session the hook fired for),host.fetch,host.llm,host.decide.host.workspaceis present only on hooks that carry an agent (marked below). - Metered only when a handler actually ran.
All 14 hooks
| Hook | Fires | Mutable (what you return) | Cancel means | Agent (workspace) |
|---|---|---|---|---|
systemPrompt |
every turn, after the system prompt is composed | systemPrompt → a string |
(ignored — a turn always needs a prompt) | yes |
userMessageInbound |
a person's message arrives (console or a channel) | text → a string |
the message is dropped | — |
interAgentMessage |
one agent's message is delivered into another agent's conversation | text → a string |
the message is not delivered | yes (the sender) |
assistantMessageOutbound |
before an agent's reply goes out to a person | text → a string |
the reply is not sent | — |
todoAdded |
an agent adds todos | texts → a string[] |
the todos are not added | — |
notifyScheduled |
an agent schedules a timer to wake itself | { message?, delaySeconds? } |
the timer is not scheduled | — |
notifyDelivered |
that timer fires | message → a string |
the wake-up is dropped | — |
sshConnectionOpening |
once per server per turn, before the first command connects | (none) | the agent's work on that server is refused | — |
sshCommandSending |
before every remote command or file operation | { command?, path?, content? } |
the operation is refused | yes |
sshCommandCompleted |
after every remote command or file operation | { output?, errorOutput? } |
the output is withheld from the model (the command already ran) | yes |
sessionClearing |
/new is about to clear a chat |
(none) | (ignored — a clear can't be vetoed) | — |
sessionClearCompleted |
/new finished clearing |
(none) | (ignored) | — |
cronJobFiring |
a schedule or heartbeat is about to fire | prompt → a string |
the run is recorded as Cancelled; nothing runs | yes |
cronJobCompleted |
a scheduled run that actually ran has closed | (none) | (ignored) | yes |
Hook reference
systemPrompt
context: { sessionId: string; agentId: string; sessionKind: string; systemPrompt: string }
returns: string
The most-used hook: add doctrine, style, or context to what the agent is told every turn. sessionKind is a
snake_case string such as "direct" or "channel". The signature has no tools argument in the types — there
is nothing to cancel.
// plugins/caveman/index.ts
globalThis.middleware.systemPrompt = async (context) => {
return context.systemPrompt + GUIDANCE;
};
Because the prompt is recomposed every turn, you can decide per agent. Oh My OpenAgents gives its doctrine only to agents that are not part of its own crew:
// infrastructure/plugins/oh-my-openagents/index.ts
globalThis.middleware.systemPrompt = async (context) => {
const { agents } = await host.agents!.list();
const isOwnedCrewMember = agents.some((agent) => agent.id === context.agentId);
return isOwnedCrewMember ? context.systemPrompt : context.systemPrompt + ORCHESTRATION_DOCTRINE;
};
userMessageInbound
context: { sessionId: string; channelId: string | null; text: string; mediaIds: string[] }
returns: string | Cancellation
channelId is null for the web console. mediaIds are the ids of attached files (read-only). Written for these
docs — mask card numbers before the model ever sees them:
globalThis.middleware.userMessageInbound = async (context) => {
return context.text.replace(/\b(?:\d[ -]?){13,19}\b/g, "[card number removed]");
};
interAgentMessage
context: {
senderSessionId: string; senderAgentId: string; targetSessionId: string;
isSubagentReport: boolean; // true when a subagent reports back to the agent that spawned it
text: string;
}
returns: string | Cancellation
Fires for every message one agent's conversation sends into another's: message_agent,
send_message_to_session, report_to_caller, the automatic reply-back when a subagent or peer finishes, and
host.session.peer.send from plugin code. It does not fire for the task text a lead hands a brand-new
subagent through start_subagent.
This is the crew's governance point: guardrails, redaction, routing notes, an audit trail. See
Agent crews for a worked example. host.workspace and
host.session here belong to the sender.
assistantMessageOutbound
context: { sessionId: string; channelId: string | null; text: string }
returns: string | Cancellation
The last look before a reply reaches a person — in the console or on a channel, including the agent's explicit
reply_to_human and send_to_channel. Cancel to stop it being delivered.
todoAdded
context: { sessionId: string; texts: string[] }
returns: string[] | Cancellation
Rewrite, split, filter or reject the todos an agent writes itself. Because open todos keep an agent working across turns, this is how a plugin shapes long missions.
notifyScheduled / notifyDelivered
notifyScheduled: context: { sessionId: string; message: string; delaySeconds: number }
returns: { message?: string; delaySeconds?: number } | Cancellation
notifyDelivered: context: { sessionId: string; notifyId: string; message: string }
returns: string | Cancellation
An agent's timers to itself (notify_session_in). Clamp delays, add context to the wake-up note, or drop it.
sshConnectionOpening
context: {
sessionId: string; agentId: string; sshHostId: string; hostDisplayName: string;
hostname: string; port: number | null; // null for a tunnel host
connectionType: "direct" | "tunnel"; username: string;
}
returns: void | Cancellation
Fires once per server per turn. Observe, log, or refuse — you can't redirect the connection. This is the place
for one-time setup with host.ssh.command (below).
sshCommandSending
context: {
sessionId: string; agentId: string; sshHostId: string; hostDisplayName: string;
kind: "command" | "list_files" | "read_file" | "write_file" | "edit_file" | "delete_file";
command: string | null; // set for kind "command"
path: string | null; // set for every file operation
content: string | null; // the text being written (write_file / edit_file); null otherwise and for binary writes
intent?: string; // what the agent said the command is for (read-only)
}
returns: { command?: string; path?: string; content?: string } | Cancellation
Written for these docs — refuse destructive commands and force a safer flag:
globalThis.middleware.sshCommandSending = async (context, tools) => {
if (context.kind !== "command" || !context.command) return {};
if (/\brm\s+-[a-z]*r[a-z]*f?\s+\/(\s|$)/.test(context.command)) {
return tools.cancel("refusing to delete the root filesystem");
}
return { command: context.command.replace(/\bapt-get install\b/, "apt-get install -y") };
};
(Returning {} changes nothing.)
sshCommandCompleted
context: {
sessionId: string; agentId: string; sshHostId: string; hostDisplayName: string;
kind: …; command: string | null; path: string | null;
exitCode: number;
output: string | null; // stdout, a read file's text, or a directory's entry names
errorOutput: string | null; // stderr for a command
isTruncated: boolean; durationMs: number;
}
returns: { output?: string; errorOutput?: string } | Cancellation
Rewrite what the model sees. Written for these docs — redact AWS keys:
globalThis.middleware.sshCommandCompleted = async (context) => {
const redact = (s: string | null) => s?.replace(/AKIA[0-9A-Z]{16}/g, "[redacted AWS key]");
return { output: redact(context.output), errorOutput: redact(context.errorOutput) };
};
Cancelling here withholds the output — the command already ran and can't be undone.
host.ssh.command — run your own command on that server
Inside the three SSH hooks only, with permission host:ssh:command (derived from the call), a plugin can run a
command on the server the hook fired for — no other.
host.ssh.command(command: string, options?: { timeoutSeconds?: number })
: Promise<{ exitCode: number; output: string; errorOutput: string; isTruncated: boolean; durationMs: number }>
- Runs through the user's login shell like the agent's own commands, but doesn't fire any SSH hook.
- Stateless (no working directory or environment carried between calls), 60 s default / 90 s max, 10 calls per invocation, each opening its own connection.
- Do setup in
sshConnectionOpening(once per server per turn), not in the per-command hooks. - A crashed invocation can be re-run: make setup idempotent.
// Written for these docs
globalThis.middleware.sshConnectionOpening = async () => {
await host.ssh!.command("command -v rg || sudo apt-get install -y ripgrep", { timeoutSeconds: 90 });
};
A non-zero exitCode is a normal result. An unreachable host or a refusal rejects the promise.
sessionClearing / sessionClearCompleted
sessionClearing: context: { sessionId; agentId; trailingText: string | null;
messageCount; subagentCount; peerThreadCount }
sessionClearCompleted: context: { sessionId; agentId; deletedMessageCount;
deletedSubagentCount; deletedPeerThreadCount }
returns: nothing
sessionClearing is your last chance to read a transcript before /new deletes it (with
host.session.conversation.read) — for example to save a summary somewhere. trailingText is the message the
user typed after /new, if any. Counts include direct subagents and peer threads only. Both hooks can fire more
than once for one clear after a crash; for a durable "it was cleared" signal use the
sessionCleared event.
cronJobFiring
context: { cronJobId: string; agentId: string; kind: string /* "cron_job" | "heartbeat" */; name: string; prompt: string }
returns: string | Cancellation
Fires only for a slot that has passed the overlap check and is about to run. Rewrite the prompt, or cancel — the run shows as Cancelled in the schedule's history and the mission never starts.
// infrastructure/plugins/cron-sentinel/index.ts
globalThis.middleware.cronJobFiring = async (context, tools) => {
host.log("info", `${MARKER} firing ${context.kind} "${context.name}" (cronJob ${context.cronJobId})`);
if (context.prompt.indexOf(CANCEL_TOKEN) !== -1) {
return tools.cancel(`${MARKER} refused: prompt carries ${CANCEL_TOKEN}`);
}
return `${context.prompt}\n\n${MARKER} fired ${context.kind} "${context.name}".`;
};
A real plugin would gate on a maintenance window, an on-call calendar or a feature flag.
cronJobCompleted
context: { cronJobId: string; cronJobRunId: string; sessionId: string; agentId: string; status: string }
returns: nothing
Fires when a run that actually ran closes (status as shown in the run history). At most once: if the
platform crashes between closing the run and calling you, the notification is lost. Don't use it for anything
that must happen.
// infrastructure/plugins/cron-sentinel/index.ts
globalThis.middleware.cronJobCompleted = async (context) => {
host.log(
"info",
`${MARKER} completed run ${context.cronJobRunId} of cronJob ${context.cronJobId}: ${context.status}`,
);
};
Loop protection
If your hook reacts to a message by sending another one (for example interAgentMessage calling
host.session.peer.send, which fires interAgentMessage again), each generation counts as a hop. Past 4 hops from
one original event, host.session is withheld for that generation — a fuse against plugins ping-ponging each
other forever. One reaction per event never comes close.