Building

Middleware hooks

The 17 points where a plugin can rewrite or veto what an agent is doing, as it happens.

View as Markdown

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.
  • tools.initiator — on the ten turn hooks (userMessageInbound, interAgentMessage, assistantMessageOutbound, todoAdded, notifyScheduled, notifyDelivered, sshConnectionOpening, sshCommandSending, sshCommandCompleted, cronJobFiring): what started the turn, set by the platform and never by the model. Its kind is user, channelSender, schedule, event, agent or platform, and isVerifiedPerson is true only when every input came from a signed-in user or a verified contact. Use it to gate an action that a schedule or another agent must not trigger on its own. It does not see text the model read through tools, so it is not proof that an action is free of planted instructions.
  • 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

  • Agent scope. Your hooks run only for the agents your installation reaches (all agents, or the agents chosen at install). A change takes effect on the next request.
  • 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; 1 s on companionSpeech), 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. A guard can opt out: list its hook in manifest.failClosed and the same fault blocks the operation instead. Only hooks that can cancel are allowed there. Keep observers fail-open.
  • 5 seconds per handler (1 second on companionSpeech). 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 on every hook, because every hook carries an agent.
  • Metered only when a handler actually ran.

All 17 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
companionBriefing once per turn, for an agent with a companion, as the "Speaking aloud" instructions are built { parts?, moves? } (ignored — a briefing always has to exist) yes
companionPerformance once per reply, when the companion's animation plan is built groups → a CompanionPerformanceGroup[] (ignored) yes
companionSpeech before each group of sentences is sent to the voice provider text → a string (ignored) yes
userMessageInbound a person's message arrives (console or a channel) text → a string the message is dropped yes
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 yes
todoAdded an agent adds todos texts → a string[] the todos are not added yes
notifyScheduled an agent schedules a timer to wake itself { message?, delaySeconds? } the timer is not scheduled yes
notifyDelivered that timer fires message → a string the wake-up is dropped yes
sshConnectionOpening once per server per turn, before the first command connects (none) the agent's work on that server is refused yes
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) yes
sessionClearCompleted /new finished clearing (none) (ignored) yes
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;
};

companionBriefing

context: {
  sessionId: string; agentId: string; companionId: string;
  parts: { name: string; text: string }[];
  moves: { name: string; description: string; motionId: string | null }[];   // motionId is read-only
  voice: { providerKindId: string; model: string } | null;                       // read-only; null = no working voice
  wardrobe: { outfits: { key: string; name: string }[]; items: { itemId: string; tagName: string; name: string; bone: string }[];
              outfitKey: string | null; wornItemIds: string[] } | null;                // read-only; null when her look has no wardrobe
}
returns: { parts?: { name: string; text: string }[]; moves?: { name: string; description: string; motionId: string | null }[] }

Edits the "Speaking aloud" instructions an agent with a companion is given. The built-in parts, in order, are speaking, cues, moves, wardrobe (only when her look has outfits or items), stage (3D looks only: the camera tags, plus the pace tags when she has a walk motion) and closing; the list of moves is printed right after the part named moves. Edit, remove or add parts and moves; blank names or texts are dropped and descriptions are shortened to 80 characters. There is no cancel. Fires once per turn, and the system prompt is cached, so the output must be the same for the same input — adding the time, for example, makes the prompt cache miss every turn. A move you add that the look doesn't have may be requested by the agent, but nothing plays. The wardrobe context lists her outfits, the installed items (up to 30 are named in the briefing) and what she wears now; you can remove the wardrobe part, and the tags still work whenever the agent or an assistantMessageOutbound plugin writes them.

// Written for these docs — ask for a pirate-style closing
globalThis.middleware.companionBriefing = async (context) => {
  return { parts: context.parts.map((part) => part.name === "closing" ? { ...part, text: part.text + "\n- Speak like a pirate." } : part) };
};

companionPerformance

context: {
  sessionId: string; agentId: string; companionId: string; messageId: string;
  motions: { id: string; name: string; tagName: string | null; description: string; roles: string[]; canPlayWhileSpeaking: boolean }[];
  groups: {
    index: number; caption: string; words: string[];                 // read-only
    mood: { emotion: string; intensity: number } | null;
    cues: { wordIndex: number; kind: "emotion" | "tone" | "effect" | "pause" | "motion" | "pace" | "camera";
            name: string; intensity: number; motionId: string | null; endWordIndex: number | null }[];
    gesture: { motionId: string; anchorWordIndex: number | null } | null;
    partsGestureMotionId: string | null;
  }[];
}
returns: the edited groups array

Edits a reply's animation plan before the browser gets it: per group of sentences, the mood, the cues, the body gesture and the extra-parts gesture. caption, words and index are always taken from the original. The server checks the result; if it has a different number of groups, an unknown motion id, a word index out of range, or a gesture that cannot play while speaking (canPlayWhileSpeaking: false), the whole edit is discarded and the original plan is used. Fires once per plan, off the path to first sound. There is no cancel; to remove all animation, return groups with no gesture and no cues.

The cue kind motion is a body move the agent asked for with [motion:name]. A pace cue's name is left, right or home (she walks across the floor on the Unity stage); a camera cue's name is a whole yaw in degrees from -75 to 75 ("30"), front or reset. A name outside those values discards the whole edit, and so does an intensity outside 0 to 1. endWordIndex is for tone cues only: the word the tone stops before (words.length = the group's end), null = until another tone or the reply ends. Wardrobe cues ([outfit:…], [hold:…]) are not passed to this hook, and cannot be added by it — an edit containing one is discarded like any other invalid edit. A plugin changes what she wears through the reply text instead (see assistantMessageOutbound).

companionSpeech

context: {
  sessionId: string; agentId: string; companionId: string; messageId: string;
  segmentIndex: number; caption: string;     // caption is read-only: what is shown
  text: string;                              // what the voice provider will read
  providerKindId: string; model: string;     // which voice provider kind and model will speak this group (read-only)
}
returns: string

Rewrites the text the voice provider reads for one group of sentences, for example to fix a pronunciation. The caption stays the same, and the platform lines the new spoken words up with the caption words. Blank text keeps the original. Not fired for thinking sounds ("hmm…"). There is no cancel. This hook sits on the path to first sound: the first group's voice cannot start until it returns, so keep it well under 100 ms (the limit is 1 s, after which it is skipped and the original text is spoken). The hook runs before the platform's own per-provider tag translation: write our cues ([laughing], [whispering]) and the platform translates them for the provider, or write the vendor's own tags ([mischievously] for ElevenLabs), which tag-aware providers pass through unchanged. Keep rewrites local — a word or two ("SQL" to "sequel"); rewriting or reordering a whole sentence breaks the word timing from that point on.

// Written for these docs
globalThis.middleware.companionSpeech = async (context) => context.text.replace(/\bSQL\b/g, "sequel");

userMessageInbound

context: {
  sessionId: string; agentId: string; channelId: string | null; text: string; mediaIds: string[];
  sender: { displayName: string; handle: string | null; isVerified: boolean; isSharedConversation: boolean };
}
returns: string | Cancellation

channelId is null for the web console. mediaIds are the ids of attached files (read-only). sender (read-only) is who typed it: a console message from a signed-in user is verified and not shared; on a channel, isVerified means the person is on that channel's people list and isSharedConversation means a group chat rather than a direct one. 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; agentId: 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.

This is also how a plugin changes a companion's wardrobe. A spoken reply may carry [outfit:<key>], [hold:<item>] and [hold:none] tags (see Companion packs); a plugin that writes one into the text makes the change, saved with the reply, exactly as if the agent had written it.

// Written for these docs — a Friday reply always starts in the party hat
globalThis.middleware.assistantMessageOutbound = async (context) => {
  return new Date().getDay() === 5 ? `[hold:party-hat] ${context.text}` : context.text;
};

todoAdded

context: { sessionId: string; agentId: 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; agentId: string; message: string; delaySeconds: number }
                  returns: { message?: string; delaySeconds?: number } | Cancellation
notifyDelivered:  context: { sessionId: string; agentId: 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)
  isBackground: boolean;       // true for a background run: its output skips sshCommandCompleted (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; isQuiet: boolean }
returns: nothing

Fires when a run that actually ran closes (status as shown in the run history; isQuiet is true when the run finished with nothing to report: the agent replied exactly NO_REPLY and sent nothing). 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.