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.
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.
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:
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 sets this up for you.
Each tools/call is metered. See 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. To wire it by hand instead, set two environment variables where the harness can see them:
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 adenydecision with a reason. ForUserPromptSubmit, 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 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.
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_CREDITSsets the signup grant.CONDENSATE_DEFAULT_TOOL_COSTsets the cost per call, which is 1 by default.CONDENSATE_TOOL_COSTSoverrides the cost for named tools.- Balances live in Postgres when
CONDENSATE_DATABASE_URLis set. Otherwise they live in a JSONL file atCONDENSATE_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 to choose which engine runs a task. Use the runtime SDK when your application needs durable Condensate threads, event recovery and task controls.