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, a string[] for texts).
    • Several fields — return an object with the fields you change; omitted fields keep their value.
    • No fields — observe-only; the return value is ignored.

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 host members a tool with the same permissions would — including host.session (the session the hook fired for), host.fetch, host.llm, host.decide. host.workspace is 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.