# Middleware hooks

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

```ts
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`](plugin-manifest.md#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`

```ts
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.

```ts
// 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:

```ts
// 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`

```ts
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.

```ts
// 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`

```ts
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`

```ts
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.

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

### `userMessageInbound`

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

```ts
globalThis.middleware.userMessageInbound = async (context) => {
  return context.text.replace(/\b(?:\d[ -]?){13,19}\b/g, "[card number removed]");
};
```

### `interAgentMessage`

```ts
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](plugin-agent-crews.md#watching-and-shaping-crew-traffic) for a worked example. `host.workspace` and
`host.session` here belong to the **sender**.

### `assistantMessageOutbound`

```ts
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](plugin-companion-packs.md#how-the-agent-changes-her-wardrobe)); a plugin that writes
one into the text makes the change, saved with the reply, exactly as if the agent had written it.

```ts
// 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`

```ts
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`

```ts
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`

```ts
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`

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

```ts
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`

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

```ts
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.

```ts
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.

```ts
// 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`

```ts
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](plugin-lifecycle-events.md).

### `cronJobFiring`

```ts
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.

```ts
// 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`

```ts
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.

```ts
// 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.
