{ }>_the modelyour agent

De-composer

Stealing Composer's Hands, Keeping Its Brain

TL;DR: Cursor's app, CLI, and SDK are all excellent and the problem here is entirely mine. My workflows live in Pi, a provider-agnostic agent harness, behind years of extensions I have not been able to port to their SDK yet, and I wanted Composer 2.5 there too rather than only in front of a Cursor client. Cursor's API does not accept arbitrary harness tools, it accepts MCP servers, so CLIProxyAPI dynamically presents the client's local tools as fake MCP servers for the Composer run, lets the official @cursor/sdk handle the Cursor side, then routes each fake-MCP tool call back to whatever is running on my machine, Pi and Claude Code alike. The SDK still carries its own native tools, and instead of patching those out I run the sidecar in a kernel jail where they find an empty filesystem and no secret worth reading.

By Jeff NashMay 28, 2026 · Updated Aug 16, 202612 min read
The Setup

The Model I Wanted in Every Workflow

If you've been following agentic coding lately, you've probably heard about Cursor Composer 2.5. The provenance matters less than the result: it was the first cheap model in a while that I trusted as a daily driver—fast, steady through long tool loops, and good enough that I stopped waiting for my Claude or Codex quota to reset. Since I started this project, Grok 4.6 has pushed the same economics further. SpaceXAI's August release matches GPT-5.6 Sol at 61 on the Artificial Analysis Intelligence Index, while its $2/M input and $6/M output pricing puts it on the cost/performance Pareto frontier. Cursor makes the deal stranger still: Grok 4.6 and Composer 2.5 sit in a separate Cursor Models pool with generous included usage even on the $20 Pro plan. The point is not which model wins this week's chart. Frontier-ish agent work is getting cheap enough to leave running, and once a model crosses that line I stop wanting it in one app and start wanting it everywhere.

The catch is structural, since as of right now Composer 2.5 is only available in Cursor, the desktop app and the Cursor CLI, and they are not doing the whole "paste this base URL anywhere, put in your API key, and go have fun" thing. None of that is a complaint about the surfaces, because the surfaces are the best part of the story. The desktop app is still the strongest argument anyone has made for an AI-native editor, and since I live in a terminal, the Cursor CLI is the one I keep going back to for how fast it is, how legible the agent loop stays when it is twelve tool calls deep, how the diffs and approvals land exactly where you expect them, and how little it asks of me to get out of its way, and it is what I point people at when they tell me agentic coding never clicked for them inside an editor. Cursor also gets the unglamorous part right, where one subscription I already pay for covers the model and the client together instead of nickel-and-diming me across three billing relationships. If I were starting from zero today I would happily run my whole day through the Cursor CLI and never build a line of this.

But I am not starting from zero, and that is the whole reason any of this exists, because it was never really about Claude Code. The workflows I could not give up live in Pi, Earendil's agent harness, built on the opposite premise from a monolithic agent app, where you adapt the harness to your workflow instead of the other way around. Pi will point at any of fifteen-odd providers, so the model itself is a config line, but everything wrapped around that config line is mine: TypeScript extensions that add my own tools and commands, skills I load on demand, packages I share between projects, RPC-mode integrations where something that is not Node drives the agent over stdin and stdout, and print-mode calls buried inside scripts that have been running unattended for a year. That is the pile I actually ship out of every day, and it is not something I can talk into speaking a different protocol just because I found a model I like.

None of that ports to a different runtime over a weekend, since Pi speaks Anthropic- and OpenAI-shaped provider APIs and that shape is the seam every one of those extensions is built against, so swapping the harness underneath means rewriting the extensions and the RPC callers sitting on top of it. Claude Code is in the pile too, and it is the client I use in the walkthrough below since it throws the most local tools at the problem, but it was never the reason I built this.

That is the actual itch, where you can love Composer 2.5, love what Cursor is doing generally, and still spend most of your day inside a harness that predates both and that I have not had time to port. Every time I dropped back into Pi, I was reaching for Opus or Codex pricing again while the Cursor subscription I already pay for sat idle. If the model were mediocre I would not have bothered, but since it is fast, cheap, and weirdly good at the exact long-running agent work I care about, I wanted it in every workflow I have rather than only the ones with a Cursor client in front of them.

This is exactly the sort of gap my CLIProxyAPI fork exists to close. CLIProxyAPI is a Go proxy server with one job: take whatever pile of AI subscriptions and OAuth logins you already pay for, Claude Code, Codex, Gemini, and the rest of the circus, and expose them behind one OpenAI- or Anthropic-shaped API on your machine. Instead of every CLI inventing its own auth circus, you point your tools at localhost, pass a key your config trusts, and say which model you want.

My fork exists because I wanted more providers and a hosted route. I added Copilot OAuth back when that was the best deal around, then Railway deployment with auth bundling so you can log in locally once and hit the proxy from a laptop, iPad, or whatever happens to be in front of you. I also added pass-through models for random OpenAI- or Anthropic-compatible endpoints and first-class Chutes support, which is basically serverless compute for open-source models; the fork fetches Chutes' model list, normalizes the names, and automatically exposes explicit chutes-* routes so I do not have to hand-maintain whatever model happens to be cheap and useful this week. Apparently my hobby is collecting AI billing relationships and pretending that is infrastructure.

The next feature was obvious: Cursor Composer via your Cursor subscription and your Cursor API key. Same theme, one base URL, many providers, many tools, now with Cursor. And there is an official answer here, a good one. Cursor publishes @cursor/sdk, a real agent runtime you can build on, and their own launch post pitches exactly this kind of use: custom harnesses, CI agents, Cursor embedded in your own products, running "on your machine or on Cursor's cloud." That is a supported, documented front door, and most labs shipping a model this good would have handed you a chat box and a waitlist instead. For anything new I write the SDK is the obvious path, and it is where my newer workflows are already headed.

What I ran into is a migration problem rather than a capability one, since porting my Pi work to @cursor/sdk would mean rewriting every extension and RPC caller I have stacked on top of a provider-agnostic harness, and the entire reason I picked that harness was that the model is supposed to be the swappable part. Some of it is worth doing eventually, some of it is scheduled work I would rather not touch while it runs clean, and some is simply further down the list than the work it exists to do, so I wanted Composer in those workflows now rather than at the end of a migration backlog. I also did not want the sketchy alternative, where I reverse-engineer Cursor's private protobuf and OAuth dance and pretend to be their app; Cursor built a front door, and walking around the side of the building would have been both rude and fragile. So the version I wanted put the SDK in exactly one place, letting the published @cursor/sdk talk to Cursor inside the proxy and then making each client's tools show up in the shape Composer knows how to call, so Pi gets Composer as one more provider entry and every extension I have written keeps working untouched.

That got the model talking, but not working. Cursor's SDK can run the Composer agent loop, but Cursor's side does not accept arbitrary Claude-style tools. It accepts MCP servers. So CLIProxyAPI has to dynamically present Claude Code's local tools as fake MCP servers, keep the SDK run alive across tool calls, and route every shell command, file read, edit, grep, and local MCP call back to the machine where Claude Code already lives.

It works now. Several wrong designs, one embarrassing filesystem leak, and one rewrite of the entire safety story later, Composer sits inside Pi and Claude Code without turning back into a chat box that talks about edits instead of making them.

The Squeeze

This Should Have Been Glue Code

I gave myself two rules before I started, both of them reasonable in isolation and annoying the second they met each other. The first rule was that I would not call Cursor's private API myself. I wanted Cursor's own published SDK to own auth, streaming, recall, and the protocol details I did not want to clone, because reverse-engineering a private endpoint and dressing myself up as the IDE is exactly the kind of cleverness that ages into a support burden and, depending on how spicy you get with it, a terms-of-service problem.

Rule two was that every tool had to run on the user's machine. If Composer wants to inspect or change the repo, that work needs to happen where Claude Code is running, against the user's files and configured tools. A proxy can translate and route; it cannot become the computer the agent is operating.

A
Use the official SDK

Cursor's code does the Cursor talking. My proxy does not clone private APIs, forge an IDE, or guess at protocol details.

B
Keep the tools local

Claude Code remains the execution environment, including the user's repo and MCP setup.

the catch: the official SDK is a full agent runtime, with a default instinct to run tools wherever the SDK process happens to live.

The clean path was also the trap: the SDK was the right way to talk to Cursor, but its default tool loop wanted to run wherever the Node process lived.

The Graveyard

Every Obvious Path Broke a Rule

The box below is the short version of what failed.

Six forks, each killed by a rule

1
Speak Cursor's wire protocol myself. This meant rebuilding undocumented protobuf and Connect framing, then personally calling the private endpoint I was trying to avoid.
2
Use a hosted Composer proxy. This fixed nothing I cared about, because now a random backend sat between me and Cursor.
3
Run the official SDK in agent mode. This was the right direction until the tools started running on the server's filesystem.
4
Hand the run to a Cursor cloud agent. Cloud agents execute in Cursor's VM against a cloned repo. My rule was my machine, my files, my MCP servers.
5
Suppress SDK tools entirely. This was safe in the same way removing the engine from a car is safe. No filesystem leak, no useful agent loop.
6
Pretend to be the Cursor IDE. This pushed me back toward forged identity headers, the line I was trying not to cross.

Running the official SDK in agent mode is the failure that mattered. The @cursor/sdk package was the obvious way to talk to Cursor without cloning their private API, because it already knew how to authenticate and manage a real Composer run. Then I ran it on a Railway box, pointed Claude Code at it, and Composer cheerfully told me it was operating inside a remote container with HOME=/root, not where my repo lives unless my laptop has been doing some very ambitious lying.

After the Railway pwd output, I was debugging tool execution location, not provider wiring. Every read and shell call had to run on my laptop through Claude Code.

The Turning Point

Why Does It Think It's Running on the Server?

Claude Code was running on my laptop, the Node sidecar was running on Railway, and the official Cursor SDK was doing the Composer run when I asked the agent something ordinary about my repo. Composer answered like a helpful little server process: its working directory was /app, its home directory was /root, and /home/jmn did not exist.

composer @ Railway (the wrong machine)
what's your working directory and home dir?
shell("pwd; echo $HOME; ls /home") → /app
HOME=/root
ls: cannot access '/home/jmn': No such file or directory
I'm running on a remote container, so I can't see your local files. My home directory is /root.
It wasn't hallucinating. It was telling the truth, which was worse.

One config line caused it: Agent.create({ local: { cwd: WORKDIR } }). In the SDK's world, "local" means "run the agent's tools in this process's local filesystem," and since the process lived on Railway, the agent's local filesystem was the Railway container. Those were real commands against Railway's disk, not hallucinated paths.

That is an easy mistake to dismiss as a config bug until you watch the agent act on it. A coding agent with tools pointed at the wrong filesystem is worse than a chat bot, because it will grep and narrate a tree for a repo your machine does not have. Composer understood the request well enough; I had accidentally handed it the wrong machine.

Which machine runs the command

bad config/app
Composer SDK
Railway shell, files, edits

Everything is technically local, just local to the wrong machine.

desired shape/home/jmn
Composer SDK
turn
Claude Code tools + MCPs

The model can run upstream; the file operations still need the local repo.

One config, two failures

1
Wrong filesystem. Read, shell, and edit calls touch the container, not the user's repo.
2
Wrong tool inventory. Claude Code's tools and MCPs are ignored while the SDK advertises its own local executor.

Removing local: just swings the pendulum too far the other way, because now the SDK no longer has the local tool loop I need. I had to intercept tool dispatch and send each call back to the client before the server touched disk.

The Body

The Smart Part Was Keeping Claude Code's Tools

The Railway leak left one requirement: tool calls leave the SDK process and finish on the machine where Claude Code runs.

I did not want Composer as a cheaper text box, I wanted it inside the harnesses I already run, Pi and Claude Code both, with the same repo tools, extensions, and MCP servers I have configured locally. The annoying constraint is that Cursor Composer does not accept arbitrary harness-defined tools over the API, it accepts MCP servers, so CLIProxyAPI has to make the client's tools look like MCP servers for the duration of the run. Claude Code is the client in the walkthrough that follows since it throws the most local tools at the problem, but nothing here is specific to it.

That is why the Node sidecar exists in the first place. CLIProxyAPI stays the local API facade; the sidecar is the little SDK host whose job is to let Cursor's own code handle auth, recall, streaming, and the protocol furniture I do not want to clone by hand.

MCP specifically, though, and not the tool-registration feature the SDK already ships, which is the part that took me embarrassingly long to reason about properly. You can pass customTools on a local agent and they register cleanly, and they execute in your Node process, and that is the entire problem in five words. My Node process is a container on Railway. Every tool-registration feature the SDK offers assumes the process running the agent loop is also the machine holding your code, which is a completely reasonable assumption for someone running the SDK on their laptop and a completely wrong one for a proxy that a laptop, an iPad, and a phone all dial into. An MCP server over HTTP is the only tool shape in the whole API that points at a network endpoint instead of a function pointer, so it is the only one that can put execution on a different computer than the one running the model loop. The fake MCP servers are not me being clever around a restriction; they are the one door in the building that opens onto the street.

Composer's view

Everything looks like MCP

Cursor accepts MCP servers, not arbitrary harness tools
mcp.claude.read
mcp.claude.edit
mcp.claude.shell
mcp.claude.grep
mcp.local.github
mcp.local.browser
outbound call
mcp.claude.shell.run
CLIProxyAPI

Mints fake MCP servers

1
Advertise
turn Claude tools into MCP-shaped server entries
2
Ticket
map MCP call ids back to parked Composer runs
3
Dispatch
send real work to Claude Code, not the bridge disk
Claude Code's machine

Real tools execute

local repo
$ shell, edit, test
GitHub MCP
browser MCP

The small lie is the useful part: CLIProxyAPI makes Claude Code's real tools look like MCP servers because that is the only tool shape Cursor Composer knows how to call. Composer thinks it called an MCP server; Claude Code actually ran the shell command, file edit, grep, or local MCP tool.

Composer still needs tools in its own shape. CLIProxyAPI builds a fake MCP catalog from Claude Code's current tool inventory, including file reads, edits, shell, grep, and any real MCP servers already configured locally. When Composer calls one of those fake MCP tools, the SDK run pauses until Claude Code returns the result.

These are MCP-shaped entries, not a little zoo of spawned MCP subprocesses. Composer sees server-shaped tools because that is the contract it understands; the bridge maps those calls back to Claude Code's existing tool inventory, which is the whole reason the same local MCP setup keeps working.

Composer also needs to be told that repo paths come from the client harness, not from the directory next to the Node process on Railway. That sounds like a small prompt detail until you watch an agent confidently explain a directory tree from the wrong machine.

System prompt

Tell the model which machine owns the tools

Defaults to SDK host
/tmp/bridge
Composer guesses from the SDK host and starts narrating the wrong filesystem.
Uses client tools
client tools
The system prompt sends repo and MCP work through Claude Code.
claim ticket queue

A paused run gets a receipt, then the same run wakes up

tool call
tool_read_1
claim
parked run
Composer waits
later
matching tool_result resumes that parked SDK run
The bridge does not replay a fake transcript and hope the model remembers the same thought. It hands the result back to the live run that asked for it.
where each part lives

Cursor upstream, fake MCP in the middle, tools local

nothing runs on the bridge by accident
Claude Code
shellreadeditgrepMCPabortresume
The repo and MCP setup stay on the user's machine.
CLIProxy
fake MCP registry
tool id map
reject fallthrough
Cursor Composer
@cursor/sdk
auth, recall, model stream
Cursor only talks through the SDK path it already expects.

The boxes are boring on purpose. Claude Code owns the tools, the Go proxy translates requests, the Node sidecar owns the Cursor SDK run, and Cursor only hears from its own SDK. The useful seam is where the SDK's unary and streaming tool paths both end up at the same executor shape, with stable protobuf oneof cases like shellArgs, grepArgs, and mcpArgs surviving minification well enough to anchor the redirect.

The bridge hooks that SDK tool path, emits a tool_call to CLIProxy, parks the Composer run, and resumes it when the client sends the matching result. The important bit is that the thing Cursor sees is MCP-shaped, while the thing that actually executes is still Claude Code's normal local tool. If the hook is missing, the call refuses instead of quietly falling back to the SDK's native executor.

Result shape took more care than the diagram admits. Cursor serializes these tool messages through protobuf, and some of the useful fields sit inside nested oneof cases, which means a plain JSON object can look right in a log and still lose the exact branch the SDK expects when it goes back into the run. The bridge has to rebuild the proper message shape before resuming Composer, which feels too fussy until the agent hangs forever because the one field it needed dissolved in transit.

what the bridge actually does
Server-side tool dispatch never runs locally on the bridge: forward to the client, return a safe stub, or error. The SDK emits roughly thirty server-side message shapes, so every case has to be handled explicitly. Some get boring synthetic answers, like a neutral request context that reveals nothing about the bridge host. The rest route out or fail closed, because one missed case is all it takes for the server to become the computer doing the work.

CLIProxy fronts a Composer run in Cursor's SDK, fabricates the MCP surface Composer is willing to use, and lets Claude Code execute every tool locally. Because the hook itself is process-global, the sidecar carries the current session through the async chain with AsyncLocalStorage; without that, two conversations can share one hook and suddenly a tool result lands in the wrong paused run.

stateful run inside stateless requests
1
user turn
Claude Code asks Composer
2
tool call
Composer pauses mid-run
3
local work
Claude Code runs tools
4
resume
same run continues

A stateless Anthropic-shaped endpoint can front a stateful SDK run if the bridge keeps the run parked between turns. A request can end with tool calls, the run can sit there paused, and the next request can carry the tool results back into the same Composer run instead of starting over from a reconstructed transcript.

/v1/messages wants one shot per request; the SDK agent can pause for tools. The bridge stores the live run id so the next request continues the same Composer run instead of faking a new transcript.

Implementation Notes

The Stuff That Burned Real Time

The broad architecture was the clean part. The time went into little mismatches that are boring to describe until one of them leaves an agent run hanging forever or quietly routes a tool toward the wrong executor. I am putting them here as notes instead of making you walk through every commit, because if you have ever debugged glue code between two agent runtimes you already know the shape of the misery.

01

I wired the seam into the wrong build

CJS
!=
ESM

The SDK ships ESM and CJS builds. I was validating one while importing the other, so the seam never fired and nothing looked broken. Now the bridge loads the intended build deliberately and refuses to boot if the seam is not present.

02

Tool ids got sanitized out from under me

call: tool:read.1
rewrite
return: tool_read_1

Claude rewrites unsupported tool-id characters, so an id with a colon or dot came back under a different name and missed the parked promise. The fix was to sanitize before storing, not after.

03

A punctuation mark almost corrupted the seam

U+2014->ASCII only

The seam lives inside generated SDK output, so injected guard strings stay ASCII. One fancy dash in the wrong place could quietly corrupt the exact line that decides where tools run, so there is an assertAscii guard now.

Most of the time went to ESM vs CJS loading the wrong bundle, tool ids getting rewritten, and streaming vs non-streaming returning different shapes. Any of those can route a tool to the wrong place without throwing.

Making It Real

The Test Has to Prove It Can Fail

The bad failure mode is simple: miss one tool case and the server starts executing it. The safety layer catches that, so if the bridge hook is missing, tools reject; if the SDK no longer exposes the expected dispatch shape, the sidecar refuses to boot instead of guessing and hoping nobody notices.

The self-test took longer than I wanted because the first version was lying to me. It pinged the bridge hooks directly, which proved that the hooks existed, and proved nothing about whether the SDK's rewritten hook ever called them. That is a green check that tests the wrong thing.

The version I trust exercises the actual rewritten hook in the SDK bundle. It turns routing off so native execution is reachable, then turns routing on so tools go to the client. A test that has never failed for the real risk does not count.

positive control
seamnative exectest catches itrouting off proves the bad path is real
real run
seamnative execclient toolsrouting on keeps execution local

CI installs a fresh @cursor/sdk, runs the hook check with routing off so native execution is reachable, then with routing on. If the bad path never fails, the test is worthless.

All of which was correct, and none of which was the thing I eventually decided I needed, though it took me another week to work out why.

The False Summit

It Worked, Which Was Not the Same as Being Done

For a few hours I thought I was done: live API, tools routing to the client, tidy commit. Review and edge cases disagreed.

✓feat(cursor): Composer via @cursor/sdk with client-side tool executiongreen
1

Session identity was keyed too loosely, so separate conversations could merge.

2

Some upstream failures came back as clean empty successes, a 200 status code lie.

3

Streaming and non-streaming paths had drifted, so one path preserved data the other dropped.

4

The first self-test proved the hook existed, not that the seam ever reached it.

I had been calling it done when CI went green and the demo path worked. The streaming path and the non-streaming path had diverged, and two conversations could share a session key if they started with the same message.

I made server-side execution a test failure you have to see before the suite passes, and I diffed the two response paths until they matched.

Patching the SDK hook took an afternoon; trusting it took longer.

The Rethink

I Was Guarding the Wrong Thing

The patch worked and the tests were honest about what they proved, but sitting with it for a week I realized that everything I had built answered exactly one question, which was whether I had caught every path the SDK could use to execute a tool. That question does not have a provable answer. A new Node API, a native addon, a worker thread, one more built-in tool, or simply the next SDK release each opens a fresh hole, and I would find out about it the same way I found out the first time, by reading a transcript where the agent cheerfully describes a filesystem that is not mine.

So I went back to the beginning and tried to delete the patch entirely, using only the surface Cursor actually supports. I advertised the client's tools over the official mcpServers loopback, wrote a .cursor/hooks.json that denies the native tools through preToolUse and beforeShellExecution, added a beforeSubmitPrompt reminder to nudge the model toward the client tools every turn, and drove the stock SDK against the live API on composer-2.5 while recording every tool call, every MCP dial, and every hook invocation.

steer mode, three live runs
1

"run a shell command to get cwd"

Composer called native shell, deny hook fired 0 times, no steering at all

2

"you MUST use mcp__cc__run_command, do NOT use native"

Composer called mcp tool, deny hook fired 0 times, works, but only because the prompt said so

3

"use your BUILT-IN shell, not mcp"

Composer called native shell, deny hook fired 0 times, deny hook did not block

The good half is that the official MCP path works with no patch anywhere, since the SDK dials the loopback server and Composer calls the client tool the moment you ask it to. The bad half is run three, where one sentence from a user was enough to send it straight back to its native shell while the deny hook sat there and did nothing. I chased that for a while, trying settingSources of both project and all, discovery roots at both local.cwd and projectDir, and hook files at both paths the docs mention, and the hooks were silent in every single combination. The SDK's own error message for customTools gives away why, because it says those are "only supported for local SDK agents" behind getLocalPlatformOptions(), and the whole family of local-execution features sits behind that same door. The word doing the work in "local SDK agent" is local, and it means local to the Node process rather than local to you, so every one of those features would have handed my tools to a Railway container with more enthusiasm than the bug I had already spent a day fixing. Steering is a preference rather than a gate, and the supported alternative to steering was never available to me in the first place.

The next idea was to stop touching Cursor's code at all and override Node's own primitives before the SDK loads, on the theory that whatever the native tools do, they eventually have to go through node:child_process, which is mine. One adversarial turn ran nine subprocess calls through my shim, and only two of them were the shell tool: the other seven were the SDK quietly running git rev-parse --show-toplevel three times and git config --get remote.origin.url four times on its own initiative, which the dispatch patch never saw at all. Neutralizing just the two zsh spawns with a fake child process that fails fast, while letting the git calls through, left the SDK perfectly happy, and the model took the denial, shrugged, and fell back to mcp__cc__run_command without being told to.

That is a better mechanism than the bundle patch for the safety property, since it is independent of the SDK version and it catches the autonomous git probes the semantic seam misses entirely. It is also still interception, and interception is still a coverage claim, just made one layer down.

The thing that finally got me out of the loop was noticing that I had been asking the wrong question the entire time. Interception asks whether I caught every way a tool could execute. Containment asks whether there is anything here worth executing against, and any way for a result to get back out. The second question is answerable, and the kernel answers it for me, underneath the SDK, where my own bugs cannot reach.

The fair objection here is that Cursor already ships a sandbox, since the SDK docs offer sandboxOptions that confine filesystem writes to the workspace, deny privileged operations, and block outbound network unless you allowlist hosts in .cursor/sandbox.json. It is a good feature and it is aimed at a different problem than mine. Its job is to keep an agent from wrecking the machine it is supposed to be working on, so confining writes to the workspace is exactly right when the workspace is the user's repo. My workspace is a scratch directory in a multi-tenant container on Railway, so confining writes to it protects nobody: the user's files were never in reach to begin with, and the things that genuinely are in reach are my own provider credentials and whatever other tenants left lying around. Sandboxing the workspace is the wrong axis when the workspace is not where the danger is. The same docs are refreshingly blunt about why this matters at all, noting that a local agent runs its tool calls without restriction and that headless mode has no human in the loop, which is the one-sentence version of the transcript where Composer told me my home directory was /root.

So the sidecar runs in a jail. bubblewrap binds the root filesystem read-only and then drops a tmpfs over $HOME, the user's repo is never mounted at all and stays reachable only through the MCP loopback back to the client, and the process starts in an empty writable workspace, so a native ls or cat lands in a room with nothing in it. The Cursor key goes to the SDK as a JavaScript argument and is then deleted out of process.env, so the shell the SDK spawns twenty seconds later inherits an environment with nothing worth printing. An egress allowlist lets the sidecar reach Cursor's API and the loopback proxy and nothing else, so a secret that somehow survives all of that still has nowhere to go, and a seccomp profile denying execve can turn the native shell into something the kernel refuses to start in the first place.

live against the real Cursor API
what the native shell triedcontrolcontained
native env sees API keysa dozen real keysnothing
native cat of the canary fileread itnot there
MCP client tool still worksyesyes
setup: a canary secret planted at ~/JAIL_POC_SECRET.txt, and a task that forces the native shell to run env, read the canary, list $HOME, and then call the client tool anyway.

The part I like least is the part that convinced me the design was right. My first pass scrubbed the environment by matching variable names against the obvious pattern of API, KEY, TOKEN, and friends, and the live run happily handed the model a variable called STAKING_RPC_URL with an api-key query parameter sitting in the middle of its value. Nothing about the name looked like a secret, because names are not where secrets live. That is the whole argument against name matching as a guarantee, and it is why the production shape keeps the Cursor key in the Go proxy and hands the sidecar a short-lived loopback token instead, with the egress block as the catch-all for whatever I failed to think of.

What I ended up with is the thing I should have built first. Real work goes to the client over the supported MCP path, the model's native tools are welcome to run whenever it feels like it, and "tools never touch the user's machine" stopped being a property I have to keep re-proving against every SDK release and became one the kernel enforces whether my code is correct or not.

The Verdict

None of this is new computer science. CLIProxy fronts a Composer run in Cursor's SDK, turns Claude Code's local tools into fake MCP servers because Cursor only accepts MCP-shaped tools, and sends the actual execution back to Claude Code, which is the only way one server process does not both talk to Cursor and touch the filesystem.

I care that Cursor traffic stays on their SDK, tools stay on my machine, and nothing in the middle executes shell or file ops.

The version I would defend is the one where I stopped trying to be clever. Nothing routes through code I patched into a package I do not own, the Cursor half runs on the published SDK exactly as documented, the client's tools reach Composer over the supported MCP path, and the guarantee that native execution cannot touch your files comes from a jail the kernel enforces rather than from my confidence that I found every branch. That last part is the only reason I trust it, since the honest description of the patch was always that it worked until the next release, and the honest description of a contained sidecar is that a native tool can run whenever it likes and still accomplish nothing.

Cursor gave me more to work with than most labs would have, between a published SDK with a documented agent runtime behind it and a model priced like they actually want you to leave it running, and none of this exists without that. If I had one thing to hand back it would be an observation rather than a complaint. The local-execution features in the SDK, the custom tools and the file hooks and the auto-review pass and the sandbox that confines writes to your workspace, all rest on the same reasonable premise, which is that the process running the agent loop is also the machine holding the code and sitting in front of the person. For the overwhelming majority of people running the SDK that premise is correct, invisible, and the reason those features feel as natural as they do.

Where I ended up is just outside that assumption, running the SDK as a service rather than as a laptop tool, with the agent loop on one machine and the user's code on another. It is a less common shape and I do not think it is the one those features were drawn for, but it is the shape you land in as soon as the proxy is the thing your laptop and your iPad both dial into. In it, anything that resolves "local" to the current process resolves it somewhere I did not want, so custom tools would have run on my Railway box, the file hooks turned out not to apply to the path I was on, and a workspace sandbox guards a scratch directory while the credentials I actually care about sit next to it. The mechanism that keeps working is the MCP server, because a URL is the one thing in the API that can name a machine other than this one, and most of what I built here is scaffolding to make that single mechanism carry the whole tool surface. A programmatic tool-execution delegate, something like a toolExecutor handed to Agent.create(), would let the SDK say "run this somewhere else" directly, and the three steer runs above are my evidence that it would be worth having.

If you already run my CLIProxy fork, you add the Node sidecar and a Cursor key, then point your harness at Composer. Setup is fiddly but the failure modes are obvious when you hit them. And the payoff is exactly what I wanted at the start: the model I could not stop thinking about, running inside Pi with every extension I have written for it still intact, running inside Claude Code, and running inside everything else already aimed at that endpoint, none of it waiting on a migration I have not had time to do.

-- Jeff

Appendix

Build Log for People Who Enjoy This Sort of Thing