1년여 전 저는 이 블로그에 커스텀 도구나 MCP 서버를 컨텍스트에 불러오지 말고, 차라리 스크립트를 더 많이 활용하라고 권하는 글을 몇 편 썼습니다. 특히 "필요한 건 코드뿐이다(Code Is All You Need)"라는 글과 MCP에는 코드가 필요하다는 글을 썼죠. 이번에 Pi 1.0에 Codemode를 통한 MCP 지원을 추가했습니다. 어떻게 보면 오래전부터 예정된 일이었지만, 누군가에게는 의외로 느껴질 수도 있겠습니다. 그래서 이 블로그에서 그 의미에 대한 제 생각을 새로 정리해 공유하려 합니다.
Pi 같은 하네스(harness)가 LLM에 호출용 도구를 제공할 때는 도구 정의를 전달하고, 이 정의는 서버 쪽에서 특정한 토큰 구조로 변환됩니다. 모델이 도구를 호출하려는 성향을 갖게 되는 것은 강화학습 과정의 결과입니다. 더 알고 싶다면 예전에 쓴 글을 참고하세요.
우리가 CLI와 bash를 강하게 선호하는 이유 중 하나는 호출을 쉽게 조합할 수 있기 때문입니다. 또 모델이 학습 과정에서 파일 시스템이 어떻게 동작하는지도 함께 익히기 때문입니다. 그래서 echo foo > /tmp/test.txt 같은 도구를 호출하면, 모델은 그 호출 이후 /tmp에 test.txt 파일이 생긴다는 사실까지 알고 있습니다.
하지만 bash에는 근본적인 한계가 있습니다. 실행 가능한 프로그램만 조합할 수 있다는 점입니다. 그런데 프로그램이 아니면서 LLM에게는 기본(native) 도구인, 그래야만 하는 것들이 있습니다.
가장 분명한 예가 read나 view_image입니다. 멀티모달 모델이 이미지를 읽어야 할 때는 cat을 쓸 수 없습니다. 하네스가 실제 이미지 페이로드를 LLM 프로토콜에 직접 넣어 줘야 하기 때문입니다.
서브 에이전트도 좋은 예입니다. 서브 에이전트를 생성하고 조율하려면 하네스가 제공하는 도구를 피하기가 어렵습니다. 이론적으로는 에이전트가 환경 변수와 Unix 소켓으로 바깥 하네스와 통신하는 CLI 도구를 제공할 수도 있겠지만, 꽤 조악한 방식입니다. 게다가 코드가 어디에서 실행되느냐 하는 또 다른 문제도 있습니다.
이를 제대로 이해하려면 각 구성 요소가 어디에서 실행되는지 좀 더 따져 봐야 합니다. 보통 서로 다른 두 시스템이 관여합니다. 하나는 두뇌인 하네스로, 한 머신에서 실행되며 신뢰할 수 있는 대상입니다. 다른 하나는 흔히 같은 머신이지만, 실제로 도구가 실행되는 곳인 손입니다. Pi에서는 이를 실행 환경(execution environment)이라고 부르는데, 모든 작업의 대상이라고 생각하시면 됩니다.
여기서 중요한 점은 하네스라는 두뇌와, bash를 돌리고 도구를 실행하는 대상 환경 사이에 경계선이 있다는 것입니다.
이렇게 둘로 나뉘면 중요한 결과가 따라옵니다. 우선 두 쪽은 서로 다른 파일 시스템에서 돌아가고 신뢰 수준도 다릅니다. 예를 들어 Gondolin 같은 샌드박스 솔루션을 쓰면 bash 쪽은 문제없이 샌드박스에 격리되지만, 하네스 자체는 격리되지 않습니다.
바로 여기서 Codemode의 역할이 드러납니다. Codemode는 LLM이 실행 환경 쪽이 아니라 하네스 쪽에서 복잡한 작업을 표현하고 조율하게 해 주는 방법입니다. Codemode는 하네스 안의 별도 샌드박스에서 실행됩니다. Pi에서는 WASM 런타임 안의 QuickJS로 돌아가며, 네트워크·파일 시스템·타이머가 없고 RAM도 제한된다는 의도적인 제약이 있습니다. 할 수 있는 일은 다른 도구를 호출하는 것뿐입니다. Scheme이나 다른 언어를 Codemode로 실행하는 것도 얼마든지 상상할 수 있습니다.
Codemode가 낯설다면, 어떤 언어(여기서는 JavaScript) 안에서 도구 호출을 내보내는 방식이라고 이해하시면 됩니다. 덕분에 LLM 컨텍스트를 거치지 않고도 호출을 조합할 수 있습니다. 이름은 이 개념을 처음 만든 Cloudflare의 친구들 덕분입니다.
예를 들어 LLM이 일반 도구 호출로 bash를 실행하면 마지막 2,000줄만 컨텍스트에 넣고, 에이전트가 더 보고 싶으면 넘친 출력이 담긴 파일을 직접 확인해야 합니다. 반면 에이전트가 같은 호출을 Codemode로 실행하면, Codemode 쪽은 더 큰 출력을 구조화된 형태로 받습니다.
무엇보다 Codemode는 JavaScript이므로 에이전트가 동시 실행 작업과 간단한 워크플로를 표현할 수 있습니다. 요즘 에이전트가 흔히 쓰는 방식은 이렇습니다. 먼저 도구 응답에서 항목 5~10개를 살펴 형태를 파악한 뒤, 나머지 n개를 처리하는 Codemode 스크립트를 작성합니다.
Codemode는 트랜스크립트에 상태를 남길 수도 있습니다. 한 Codemode 호출이 데이터를 저장해 두면 같은 세션의 다음 호출이 그것을 다시 불러올 수 있습니다. 참고로 이는 샌드박스가 아니라 하네스 호스트에서 일어나는 일입니다.
Pi에서는 기존 인터페이스에서는 말이 되지 않는 호출도 Codemode로 할 수 있습니다. 이미지 모델로 이미지를 생성하거나 원샷 분류 모델로 텍스트를 분류하는 Pi API가 대표적입니다. 이런 API는 Codemode로만 노출하고, 일반 도구로는 노출하지 않습니다. 일반 도구로 두면 컨텍스트만 낭비하기 때문입니다.
이야기를 충분히 했으니 이제 좀 더 구체적으로 보여 드리겠습니다. 제가 최근 Pi 세션에서 실행한 Codemode 호출 몇 가지를 살펴보죠. 이 코드는 전부 사람이 쓴 것이 아닙니다. 실제 Pi 세션에서 나온 코드이고, 보기 편하도록 들여쓰기만 다시 맞췄습니다. 에이전트는 모델이 해당 작업에서 자연스럽게 Codemode를 택하거나, 사용자가 요청하면 자동으로 Codemode를 쓰기 시작합니다.
Pi에서 Codemode는 기본적으로 MCP를 켰을 때만 활성화됩니다. 하지만 설정에서 "defaultTools": ["+codemode"]로 켤 수 있으니, Pi에게 켜 달라고 요청하면 됩니다.
간단한 이미지 생성부터 시작해 보겠습니다. 이미지 생성은 Pi가 AI SDK 코어에서 지원하는 기능이지만, 에이전트가 쓸 수 있는 도구는 아닙니다. 지금까지 이미지 모델을 쓰려면 전용 확장을 만들거나, 에이전트가 node를 직접 실행해 내부 이미지 API를 호출하게 하는 수밖에 없었습니다. 그런데 Codemode에서 내부 모델 API를 상당수 노출하므로, 에이전트가 이를 바로 사용할 수 있습니다.
const [painter] = await models.getAvailableOfType("image");
const result = await models.generateImages(painter, {
input: [{ type: "text", text: "A cute little puppy sitting on a grassy " +
"lawn, soft natural light, photorealistic" }],
});
if (result.stopReason !== "stop") return result.errorMessage;
for (const block of result.output) {
if (block.type === "image") image(block);
else text(block.text);
}
image() 호출은 이미지를 이미지 콘텐츠로 LLM에 돌려보냅니다. 하네스 쪽에서는 이를 에이전트에 직접 전달하는 동시에, 에이전트가 이미지를 bash에 다시 넘겨야 할 때를 대비해 임시 아티팩트로 디스크에도 저장합니다.
Jev 같은 분류 모델도 마찬가지입니다. 일반적인 도구로는 에이전트의 워크플로에 잘 맞지 않습니다. 하지만 전용 도구를 만들지 않아도, Codemode를 쓰면 에이전트가 AI SDK에 직접 접근해 모델을 호출할 수 있습니다. 아래는 Jev로 GitHub 이슈를 대량 처리해 간단한 감성 분석을 하는 예입니다.
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const r = await tools.bash({
command: "gh issue list --state open --limit 100 " +
"--json number,title,body,comments",
});
const issues = JSON.parse(r.output);
const results = await Promise.all(issues.map(async (issue) => {
const res = await models.classify(jev, {
state: {
title: issue.title,
body: (issue.body || "").slice(0, 4000),
comments: issue.comments.slice(-5).map(c => c.body.slice(0, 800)),
},
questions: {
sentiment: {
type: "choice",
instructions: "What is the overall sentiment of the author towards pi?",
criteria: {
positive: "Appreciative, happy, constructive praise",
neutral: "Matter-of-fact report or request without emotion",
negative: "Frustrated, annoyed, upset, or angry",
},
},
frustration: {
type: "score",
instructions: "How frustrated is the reporter?",
criteria: ["not at all", "mildly", "clearly frustrated", "very angry"],
},
kind: {
type: "choice",
instructions: "What kind of issue is this?",
criteria: {
bug: "Bug report or regression",
feature: "Feature request or enhancement",
question: "Question or support request",
other: "Docs, discussion, meta, spam",
},
},
},
});
if (res.stopReason !== "stop") {
return { n: issue.number, title: issue.title, error: res.errorMessage };
}
return { n: issue.number, title: issue.title, ...res.answers };
}));
store("sentiment_results", results);
return results
.filter(r => !r.error)
.sort((a, b) => b.frustration.score - a.frustration.score)
.slice(0, 12)
.map(r => `#${r.n} ${r.frustration.score.toFixed(2)} [${r.kind.choice}] ${r.title}`);
위 예제에서는 store()도 호출하는데, 실행 결과를 세션 트랜스크립트에 기록합니다. 이후 Codemode를 호출할 때 필요하면 그 결과를 다시 읽을 수 있습니다.
여기서 Promise.all를 써도 문제없습니다. Pi가 동시 도구 실행 수를 4개로 제한하고 나머지는 큐에 쌓아 두기 때문입니다.
좀 더 모험적인 예로, Jev로 게임 엔진을 구동해 디버깅하는 경우가 있습니다.
에이전트는 제 tankctl 명령어를 알고 있었고, 이를 감싸는 최소한의 하네스를 스스로 빠르게 만들어 게임 루프를 구동하며 사용자의 문제 디버깅을 도왔습니다. 30단계짜리 루프를 만들어서, 매 단계마다 게임 엔진에서 현재 상황의 텍스트 덤프를 받고, 그다음 Jev에게 다음 행동을 판단하게 한다는 점에 주목해 보세요.
const jev = await models.getModelOfType("classifier", "typesafe", "jev-latest");
const tank = async (cmd) =>
(await tools.bash({ command: `tools/tankctl "${cmd}"` })).output;
await tank("start --map assets/maps/night_arena.map");
const questions = {
action: {
type: "choice",
instructions: "You control the tank '@' in a top-down tank game. " +
"Choose the best next action.",
criteria: {
attack: "an enemy has line of sight to you and you can fire at it",
approach: "no enemy has line of sight; drive toward the nearest enemy",
dodge: "an enemy shot is heading at you and will hit soon",
powerup: "a powerup is close and no enemy threatens you",
},
},
};
function commandFor(choice, st) {
const p = st.player;
const enemy = st.enemies.filter(e => !e.dead)
.sort((a, b) => (b.los - a.los) || (a.dist - b.dist))[0];
if (choice === "attack" && enemy) {
return `fire_at tank ${enemy.id}; frames 30 until clear,damage,kill`;
}
if (choice === "dodge") {
// move perpendicular to the closest incoming shot
const s = st.projectiles.filter(s => !s.yours)
.sort((a, b) => a.eta - b.eta)[0];
const dir = s && Math.abs(s.vel[0]) > Math.abs(s.vel[1])
? (p.pos[1] > s.pos[1] ? "+down" : "+up")
: (p.pos[0] > (s ? s.pos[0] : 0) ? "+right" : "+left");
return `input ${dir}; frames 20 until damage; input stop`;
}
const powerup = st.powerups.filter(u => u.available)
.sort((a, b) => a.dist - b.dist)[0];
if (choice === "powerup" && powerup) {
return `goto ${powerup.pos[0]} ${powerup.pos[1]} 180`;
}
return enemy ? `goto ${enemy.pos[0]} ${enemy.pos[1]} 90` : null;
}
const log = [];
for (let step = 0; step < 30; step++) {
const st = JSON.parse(await tank("state"));
if (st.state !== "playing") break;
const threats = st.projectiles
.filter(s => !s.yours && s.miss_dist < 1.5 && s.eta < 1.5)
.map(s => `incoming shot dist ${s.dist} eta ${s.eta}s`)
.join("\n") || "no incoming shots";
const r = await models.classify(jev, {
state: { map: await tank("view 8"), threats, hp: st.player.hp },
questions,
});
if (r.stopReason !== "stop") {
log.push(`#${step} classifier error: ${r.errorMessage}`);
break;
}
const choice = r.answers.action.choice;
const cmd = commandFor(choice, st);
if (!cmd) break;
log.push(`#${step} hp=${st.player.hp} ${choice} -> ${await tank(cmd)}`);
}
return log.join("\n");
마지막으로, Codemode는 당연히 MCP 서버를 호출하는 데도 아주 좋습니다. MCP 도구는 LLM에 하나도 노출하지 않기 때문에, 에이전트는 먼저 제공된 API로 Codemode 안에서 도구 검색을 실행해 연결된 서버로 무엇을 할 수 있는지 파악합니다. 이런 점진적 탐색 방식 덕분에 지금도 많은 사용 사례에서 MCP가 충분히 잘 동작합니다.
아래 예에서 에이전트는 도구를 탐색하지도 않고 곧바로 Sentry MCP를 꺼내 듭니다. 강화학습 과정에서 Sentry MCP가 어떻게 생겼는지 이미 배웠기 때문으로 보입니다. 다만 Sentry 서버를 쓸 수 있다는 사실 자체는 시스템 프롬프트에 주입한 내용에서 알게 됩니다. 완전히 짐작으로 하는 것은 아닙니다.
const orgs = await tools.mcp__sentry__find_organizations({});
const { organizations } = orgs.structuredContent;
const results = await Promise.allSettled(organizations.map(org =>
tools.mcp__sentry__find_projects({
organizationSlug: org.slug,
regionUrl: org.regionUrl,
})
));
return organizations.map((org, i) => {
const r = results[i];
if (r.status !== "fulfilled") return { org: org.slug, error: String(r.reason) };
if (r.value.isError) return { org: org.slug, error: r.value.content };
return {
org: org.slug,
projects: r.value.structuredContent.projects.map(p => p.slug),
};
});
여기서 MCP 이야기를 길게 하고 싶지는 않습니다. 다만 MCP는 사실 Codemode의 덕을 크게 보는 프로토콜입니다. 문제는 MCP가 실제로는 아직(?) Codemode를 쓰지 않는 하네스를 대상으로 하는 경우가 많다는 점입니다. 하지만 흐름은 바뀌고 있습니다. 그동안 임시방편으로 Cloudflare가 했던 것처럼 MCP 서버 안에서 Codemode를 실행하기도 했습니다. 그러다 보니 Codemode 안에 Codemode가 들어가는 꽤 나쁜 상황이 됐습니다. JSON 이스케이프가 이중으로 필요하고, 작은 모델은 쉽게 혼동하며, 안쪽 코드는 바깥쪽 도구를 호출할 수 없습니다. 예를 들어 Pi에서 Cloudflare MCP 서버를 쓰면 에이전트는 JavaScript를 작성해 또 다른 JavaScript에 넘겨야 합니다. 전혀 최적이 아니지만, 이런 일이 벌어지는 것도 이해는 갑니다.
const accRes = await tools.mcp__cloudflare__execute({
code: `async () => {
const r = await cloudflare.request({ method: "GET", path: "/accounts" });
return r.result.map(a => ({ id: a.id, name: a.name }));
}`,
});
const accounts = JSON.parse(accRes.content.map(c => c.text).join(""));
const out = [];
for (const account of accounts) {
const r = await tools.mcp__cloudflare__execute({
account_id: account.id,
code: `async () => {
const r = await cloudflare.request({
method: "GET",
path: \`/accounts/\${accountId}/workers/scripts\`,
});
return r.result.map(s => ({ id: s.id, modified: s.modified_on }));
}`,
});
out.push({ account: account.name, workers: r.content.map(c => c.text).join("") });
}
return out;
그렇다면 지금 Codemode는 MCP와 얼마나 잘 맞을까요? 솔직히 말해 아주 잘 맞지는 않습니다. MCP 서버가 아직 Codemode를 쓰는 하네스를 대상으로 만들어지지 않았기 때문입니다(지금은 대부분의 하네스가 Codemode를 지원한다고 생각하지만요).
잘 동작하게 하려면 다음을 권장합니다.
outputSchema 체계가 이를 구현하기에 좋습니다.그럼 앞으로는 어떻게 될까요? CLI를 권장했던 1년 전 글과 반대되는 입장일까요? 저는 그렇게 생각하지 않습니다. 오히려 제 관점에서 MCP 생태계는 우리가 1년 전 효과가 있다고 짚었던 바로 그것, 즉 코드를 받아들였습니다. 다만 Codemode는 MCP를 넘어, 하네스 안에서 에이전트에게 더 많은 자유를 주는 강력한 수단으로 쓸 수 있습니다.
아직 풀어야 할 문제도 있습니다. 우선 Codemode에서는 내구성(durability) 확보가 더 까다롭습니다. 호출의 스냅샷을 남기려면 내구성 있는 워크플로 엔진의 아이디어를 일부 가져와야 할 수도 있습니다. 또는 결정적(deterministic)이라는 성질을 생각하면 Starlark 같은 언어가 JavaScript보다 나은 조합 언어일지도 모릅니다.
이미지와 바이너리 데이터, 그리고 이 패턴이 작은 모델에서는 잘 동작하지 않는다는 점도 더 다듬어야 합니다. 그래서 아직 완벽한 해법은 아니지만, 앞으로 더 많이 활용하게 될 만큼 꽤 쓸모 있는 패턴이라고 생각합니다.