# Connect tools and hooks (https://condensate.dev/guides/middleware/)

> Give an existing harness Condensate tools, hooks and policy over MCP.

Middleware gives a compatible agent harness one MCP endpoint for tools and sends the harness's lifecycle events to Condensate's policy checks. The harness keeps its own model loop. This guide connects an upstream tool server, adds lifecycle hooks, installs the Condensate plugin, and covers steering, consent tiers, code mode and credits.

[Diagram: Two connections, two responsibilities. MCP routes tool calls. Hooks carry lifecycle events and decisions. Both paths must be configured in the client harness.]

## Connect an upstream tool server

Start with an MCP server you already run. Middleware gathers the tools from every upstream you configure and serves them from one endpoint. This example uses an HTTP upstream to keep the connection visible. Stdio upstreams work too, as described below.

```sh
export CONDENSATE_HOST=127.0.0.1
export CONDENSATE_MCP_UPSTREAMS='[{"name":"tools","url":"http://127.0.0.1:9000/mcp","auth":"none"}]'
export CONDENSATE_LOCAL_PRINCIPAL_ID=local-dev
export CONDENSATE_LOCAL_API_KEY="$(openssl rand -hex 32)"

bun apps/middleware/backend/index.ts
```

Run this from the repository root. The backend listens on port **4787** unless you set `CONDENSATE_PORT`. Setting `CONDENSATE_HOST=127.0.0.1` keeps the listener on loopback. Without it, the backend accepts connections on every interface.

Middleware needs a way to check callers before it serves `/mcp`, `/hooks`, `/watcher/*` or `/credits/topup`. Without one, those routes refuse requests with HTTP 503. For a single-operator machine, set `CONDENSATE_LOCAL_API_KEY`. The key is checked in process, with no network call. A hosted deployment uses `EXECUTOR_URL` or WorkOS sign-in (`WORKOS_CLIENT_ID`) instead. If both `EXECUTOR_URL` and `CONDENSATE_LOCAL_API_KEY` are set, the backend refuses to boot.

`CONDENSATE_LOCAL_PRINCIPAL_ID` names the caller that the key belongs to. Credits, consent tiers and code-mode allow-lists are all keyed on that id, so use the same id everywhere you refer to this caller.

### Upstream entries

`CONDENSATE_MCP_UPSTREAMS` takes a JSON array. Each entry needs a `name` that is unique and contains no double underscore, because `__` separates the upstream name from the tool name. An entry uses either `url` (HTTP) or `command` (stdio), never both.

| Field | Used with | Meaning |
| --- | --- | --- |
| `url` | HTTP | The upstream's MCP endpoint. Must be http or https. |
| `auth` | HTTP | How middleware authenticates to the upstream. See the table below. Defaults to `none`. |
| `headers` | HTTP | An object that maps header names to env var references such as `"$UPSTREAM_KEY"`. Cannot be combined with `auth`. |
| `command` | stdio | The program to start. Middleware reaps these child processes on shutdown. |
| `args`, `env`, `cwd` | stdio | Arguments, environment and working directory for the child. Stdio entries take no `auth`, so put credentials in `env`. |

| `auth` value | What middleware sends upstream |
| --- | --- |
| `none` | No credential. |
| `bearer:$ENV_NAME` | `Authorization: Bearer` with the value of that environment variable. The variable must be set at boot. |
| `byok` | The caller's `x-upstream-key-<name>` header, forwarded as `Authorization` to that upstream only. It is never stored or logged. |
| `x402-key` | The caller's own executor API key, for gateways that meter by key. |
| `trust:$ENV_NAME` | A shared trust token from that environment variable, sent as `x-condensate-trust` so a metering gateway skips its per-call payment check. The variable must be set at boot. |
| `{ "headers": { "Name": "$ENV" } }` | Arbitrary headers, each read from an environment variable. |

Any other value stops the backend from booting.

The `auth` in the example describes the connection from middleware to the upstream server. Choose the mode your own upstream expects. Notion, Linear and GitHub are hosted providers with pinned auth modes. Turn them on with their `CONDENSATE_MCP_<PROVIDER>_URL` and credential variables, or list them in `CONDENSATE_MCP_PROVIDERS`, rather than writing their auth by hand.

Set `CONDENSATE_MCP_TOOL_SURFACE` to control what `tools/list` advertises. `full` (the default) lists every tool with its schema. `lean` advertises only the built-in search tools, plus the code-mode tools for principals allowed to use them, and tells clients to find everything else through `codemode__search`. Use `lean` only together with code mode. Calls keep the same authority under either setting.

## Inspect the tool catalog

In another terminal, set `CONDENSATE_LOCAL_API_KEY` to the same key used by the backend. Then request the catalog:

```sh
curl --fail-with-body http://127.0.0.1:4787/mcp \
  -H "x-condensate-key: $CONDENSATE_LOCAL_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

A successful JSON-RPC response contains `result.tools`. The endpoint takes POST requests. A GET returns HTTP 405.

Aggregated tool names take the form `<upstream>__<tool>`, so a `read` tool on the `tools` upstream becomes `tools__read`. The endpoint also accepts `initialize` and `ping`. Middleware also serves `harness__set_mode`, which switches the session's harness profile. A switch that narrows authority applies at once. A switch that widens it waits for a person to confirm it. The names `harness` and `codemode` are reserved, so do not use them for an upstream.

Configure your harness's HTTP MCP client with:

| Setting | Value |
| --- | --- |
| URL | `http://127.0.0.1:4787/mcp` |
| Header | `x-condensate-key: YOUR_LOCAL_KEY` |

The client configuration format depends on the harness. Use its supported HTTP MCP setup rather than copying one client's configuration into another. For Claude Code and Codex, the [Condensate plugin](#install-the-condensate-plugin) sets this up for you.

Each `tools/call` is metered. See [Credits and metering](#credits-and-metering) below.

## Add lifecycle hooks

MCP supplies tool access. A hook integration connects the harness's events to middleware policy. The hook shim reads each event, asks middleware for a verdict, and applies that verdict with the harness's own contract.

The shim works with Claude Code and Codex. It is a single command that reads one event from stdin. The simplest way to install it is the Condensate plugin. See [Hook up Claude Code and Codex](https://condensate.dev/guides/connect-agents/). To wire it by hand instead, set two environment variables where the harness can see them:

```sh
export CONDENSATE_KEY="$CONDENSATE_LOCAL_API_KEY"   # the same key the backend checks
export CONDENSATE_URL=http://127.0.0.1:4787          # optional, this is the default
```

Then register the shim as a command hook for `UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `Stop`. Use the absolute path to `apps/middleware/hook-shim/condensate-hook.ts`, and run it with `bun`. The shim sends the key as `x-api-key`. Middleware picks the event format from the `x-condensate-harness` header, which accepts `claude-code` or `pi`. The shim does not send that header, so its events use the default, `claude-code`. This holds under Codex too, because the plugin runs the same shim there. A harness that sends `pi` in that header uses the `pi` format instead. Any unknown value returns HTTP 400.

### What happens on a fault

A fault means the shim has no key, cannot reach middleware, gets a non-2xx status, gets a body it cannot parse, or gets a verdict that fails validation. The fault rule depends on the event:

| Event | Can policy deny? | On a fault |
| --- | --- | --- |
| `UserPromptSubmit` | Yes. The prompt is blocked. | Denied, with a reason that names the fault. |
| `PreToolUse` | Yes. The tool call is blocked. | Denied, with a reason that names the fault. |
| `PostToolUse` | No. The tool already ran. | Continues, with one warning on stderr. |
| `Stop` | No. It can only inject continuation. | Continues, with one warning on stderr. |

The policy path fails closed. The shim makes at most two attempts, each with a 400 millisecond timeout, so one hook call spends at most 800 milliseconds waiting on middleware.

Other errors fail open. A malformed event, a verdict the shim cannot apply to that event, or an unexpected shim error continues with one warning on stderr. `CONDENSATE_HOOK_FAIL_OPEN=1` turns every fault into a continue. Use it for local development only, because it removes the policy check.

### Verdicts

Middleware returns one of three verdicts for a hook event:

- **Allow.** The event continues with no output.
- **Deny.** The event is blocked where the harness allows it. For `PreToolUse`, the shim returns a `deny` decision with a reason. For `UserPromptSubmit`, the reason goes to stderr and the prompt is blocked.
- **Inject.** Middleware asks the shim to add a short message. The shim renders the message from its own template library, so the backend never sends free-form text. A labeled injection also adds one system message line for the harness. The line shows counts, the intervention level and a ledger id, but not the injected text.

## Install the Condensate plugin

The Condensate plugin in `packages/condensate-plugin` packages the hook shim, a connection to the middleware MCP endpoint (including the code-mode tools) and a set of agent skills. One plugin serves both Claude Code and Codex. Both hosts run the same bundled hook script. No model traffic passes through the plugin.

[Hook up Claude Code and Codex](https://condensate.dev/guides/connect-agents/) has the install steps for both hosts, a check that proves the connection works, and fixes for common faults.

The two hosts reach MCP in different ways. Claude Code connects straight to `${CONDENSATE_URL}/mcp` over HTTP and sends the key as `x-api-key`. Codex starts a small stdio bridge (`mcp-bridge.ts`) that forwards each MCP request to that same endpoint with the same header. Codex passes `CONDENSATE_KEY` and `CONDENSATE_URL` from its own environment to the bridge.

The plugin registers `UserPromptSubmit`, `PreToolUse`, `PostToolUse` and `Stop`. It also sends `SessionStart`, `SessionEnd`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop` and `Notification` when the host supports them. Middleware treats those extra events as observations. The fault rules above apply the same way under both hosts.

## Steering runtime sessions

Steering is on by default once the middleware key is in place. You can set a holdout share of sessions that is never steered (`CONDENSATE_HOLDOUT_FRACTION`, 0 by default). The runtime attaches every process session to the middleware at `http://127.0.0.1:4787`, with the key it reads from `~/.config/condensate/middleware/api-key`. The file holds the key on one line, with no group or other access (mode 0600). The middleware must accept the same key, for example as `CONDENSATE_LOCAL_API_KEY`. Set `CONDENSATE_MIDDLEWARE_URL` or `CONDENSATE_MIDDLEWARE_API_KEY_FILE` on the runtime to name another middleware or key file. When the middleware starts through `apps/middleware/scripts/start-local.sh` and the key file is missing, it writes the file from its own `CONDENSATE_LOCAL_API_KEY`, mode 0600, and never overwrites a file that exists. Restart the runtime after that first write. If the default key file is missing and `CONDENSATE_MIDDLEWARE_URL` is not set, the runtime starts sessions without steering, logs `steering off: <reason>` at boot, and reports `steering` as off with the reason in its `/health`. Dex's Box section shows the same line. With that URL set, or with a key file that grants group or other access, every spawn fails with the reason. Set `CONDENSATE_STEERING=off` to turn steering off.

## Steering judge

Middleware runs a set of detectors over each session. They fire on repeat counts, line thresholds and regular expressions. A detector cannot tell a stuck session from a careful one that repeats itself on purpose, so the steering judge reviews each firing. It asks TypeSafe whether the firing is real and which level of intervention the harness should take.

`CONDENSATE_STEERING_JUDGE` sets the mode:

| Mode | What happens |
| --- | --- |
| `off` | The judge is not built. No firing reaches TypeSafe. |
| `shadow` | The judge reviews every firing and logs its verdict beside the detector's own decision. Nothing a session reads changes. This is the default when `TYPESAFE_API_KEY` is set. |
| `gate` | The judge's answer decides. A firing judged not real (below `CONDENSATE_JUDGE_REAL_FLOOR`, default 0.5) is suppressed. A real one is decided at the level the judge asks for. |

In `gate` mode the judge's level is capped by the principal's consent tier, so the judge cannot ask for more intervention than the tier allows.

## Set consent tiers

Consent tiers set how far middleware may intervene in a principal's sessions. Set `CONDENSATE_CONSENT_TIER` to choose a deployment-wide tier. When it is unset, the backend keeps its ungated default. Set an explicit tier when you want a restrictive default.

| Tier | What middleware may do |
| --- | --- |
| `observe` | Record observations. No intervention. |
| `annotate` | Annotate sessions and deny governed calls. |
| `steer` | Also send steering turns. |
| `autonomous` | Also force continuation and interrupt. |
| `supervised` | Per principal only, through `CONDENSATE_SUPERVISED_PRINCIPALS`. Watcher and advisor models may receive permitted session information. Not accepted as the deployment-wide tier. |

## Code mode

Code mode lets a model run one short program that calls several tools and returns a concise result. Each tool call is still a normal, metered tool call. The program only avoids a separate model round trip for each intermediate value.

```text
Without batching:
model → tool A → model → tool B → model → tool C → model

With a code-mode program:
model → program calls A, B, C → selected result → model
```

This is an execution sketch, not code to paste into the sandbox.

Code mode is off by default. To switch it on, set `CONDENSATE_CODE_MODE` to `opt-in` or `internal` (`off`, or leaving it unset, keeps it off), and list the principals who may use it in `CONDENSATE_CODE_MODE_PRINCIPALS` as a comma-separated list. With no principals listed, the backend warns at boot and nobody sees the tools. Under either value, only the listed principals see the code-mode tools. Other callers see nothing. The two values behave the same today. Any other value stops the backend from booting, so a typo cannot silently turn a rollout off.

Code mode adds three tools under the `codemode` upstream:

| Tool | What it does |
| --- | --- |
| `codemode__search` | Finds tools by words describing the task. `limit` runs from 1 to 20, default 8. |
| `codemode__types` | Returns TypeScript signatures for the merged tool surface. `docs: true` adds JSDoc descriptions. |
| `codemode__run` | Runs one JavaScript program in a QuickJS sandbox. Inside it, `await call("<upstream>__<tool>", args)` calls any aggregated tool. The program ends with `return`, and only that value comes back. |

Programs run under fixed caps. Each cap returns a typed error that names it, so nothing is cut off silently.

| Cap | Default |
| --- | --- |
| Wall-clock time per run | 10 seconds. Some surfaces raise this for a longer run. |
| Tool calls per run | 20 |
| Namespace calls per run | 100 |
| Reply timeout per tool call | 180 seconds |
| Returned result size | 64 KiB of JSON |

Only one run is allowed at a time per principal. Native namespaces, such as `agents`, `canvas` and `review`, are host objects rather than tools. They are bound only when their service URL is configured, and they count toward the namespace-call cap, not the tool-call cap.

## Credits and metering

When `/mcp` is on, every `tools/call` debits prepaid credits. A new caller receives a signup grant. A failed metered call is refunded.

- `CONDENSATE_FREE_SIGNUP_CREDITS` sets the signup grant.
- `CONDENSATE_DEFAULT_TOOL_COST` sets the cost per call, which is 1 by default.
- `CONDENSATE_TOOL_COSTS` overrides the cost for named tools.
- Balances live in Postgres when `CONDENSATE_DATABASE_URL` is set. Otherwise they live in a JSONL file at `CONDENSATE_CREDITS_PATH`. With neither, they stay in memory, the backend logs a warning, and balances reset on restart.

## Which connection to use

Use middleware to add a tool and policy layer to a harness you already run. Read [harnesses and models](https://condensate.dev/concepts/harnesses/) to choose which engine runs a task. Use [the runtime SDK](https://condensate.dev/guides/sdk/) when your application needs durable Condensate threads, event recovery and task controls.
