condensate

Build and connect

Connect tools and hooks

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

On this page

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.

Two connections, two responsibilities
Tool access
HarnessRequests a tool
MCP endpointChecks and routes the call
UpstreamExecutes the tool
Policy hooks
Harness eventTriggers a wired hook
PolicyProduces a verdict
Hook shimReturns it to the harness
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.

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.

FieldUsed withMeaning
urlHTTPThe upstream's MCP endpoint. Must be http or https.
authHTTPHow middleware authenticates to the upstream. See the table below. Defaults to none.
headersHTTPAn object that maps header names to env var references such as "$UPSTREAM_KEY". Cannot be combined with auth.
commandstdioThe program to start. Middleware reaps these child processes on shutdown.
args, env, cwdstdioArguments, environment and working directory for the child. Stdio entries take no auth, so put credentials in env.
auth valueWhat middleware sends upstream
noneNo credential.
bearer:$ENV_NAMEAuthorization: Bearer with the value of that environment variable. The variable must be set at boot.
byokThe caller's x-upstream-key-<name> header, forwarded as Authorization to that upstream only. It is never stored or logged.
x402-keyThe caller's own executor API key, for gateways that meter by key.
trust:$ENV_NAMEA 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:

SettingValue
URLhttp://127.0.0.1:4787/mcp
Headerx-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:

EventCan policy deny?On a fault
UserPromptSubmitYes. The prompt is blocked.Denied, with a reason that names the fault.
PreToolUseYes. The tool call is blocked.Denied, with a reason that names the fault.
PostToolUseNo. The tool already ran.Continues, with one warning on stderr.
StopNo. 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 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:

ModeWhat happens
offThe judge is not built. No firing reaches TypeSafe.
shadowThe 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.
gateThe 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.

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.

TierWhat middleware may do
observeRecord observations. No intervention.
annotateAnnotate sessions and deny governed calls.
steerAlso send steering turns.
autonomousAlso force continuation and interrupt.
supervisedPer 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:

ToolWhat it does
codemode__searchFinds tools by words describing the task. limit runs from 1 to 20, default 8.
codemode__typesReturns TypeScript signatures for the merged tool surface. docs: true adds JSDoc descriptions.
codemode__runRuns 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.

CapDefault
Wall-clock time per run10 seconds. Some surfaces raise this for a longer run.
Tool calls per run20
Namespace calls per run100
Reply timeout per tool call180 seconds
Returned result size64 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 to choose which engine runs a task. Use the runtime SDK when your application needs durable Condensate threads, event recovery and task controls.