Most developers think AI coding assistants are just fancy autocomplete, but Cursor’s Agent Mode runs a full plan‑act‑observe loop that can call tools, edit files, and verify results. In a few minutes you’ll see exactly how the loop is orchestrated and how to hook it into a tiny Node.js 22 CLI without a build step.
What Agent Mode Actually Does
Why you care – When you ask an AI to “write a function that parses CSV”, you expect a single snippet. In reality the AI often needs to (a) decide what steps are required, (b) execute those steps (like creating a file or running a test), and (c) check whether the outcome matches the original request. Agent Mode gives the model that extra “brain” so it can act like a junior developer you can supervise.
Key terms
- Agent – an AI instance that can run a loop of “plan → act → observe” and keep state between iterations.
- Tool – any external capability the agent can invoke, such as a file‑system write or an HTTP request.
- Endpoint – the URL you call to start or continue a conversation with the agent.
Cursor’s public endpoint lives at https://api.cursor.com/v1/agent. It expects a JSON payload describing the current instruction and a list of available tools. The response contains the next “action” the model wants to take and a short “thought” explaining why.
In plain English: Agent Mode is like a robot that thinks out loud, does something, looks at the result, and then decides what to do next.
Minimal request shape
{
"messages": [
{ "role": "system", "content": "You are a helpful coding assistant." },
{ "role": "user", "content": "Generate a TypeScript function that adds two numbers." }
],
"tools": [
{ "type": "file_write", "description": "Write a file to the local repo." },
{ "type": "run_command", "description": "Execute a shell command and capture output." }
],
"session_id": "my-cli-session-001"
}
The session_id lets the service keep the loop’s memory across calls.
Tip: Keep the
session_idstable for the whole CLI run; otherwise the model will forget earlier steps and start over.
The Plan‑Act‑Observe Loop in Cursor AI
Why the loop matters – A single prompt can’t reliably produce correct code every time. By giving the model a chance to test its own output, we dramatically reduce bugs. Think of it as a chef tasting a sauce before adding the next spice.
Step‑by‑step breakdown
- Plan – The model reads the user request and proposes a series of actions (e.g., “write file”, “run test”).
- Act – Your code executes the proposed action using the corresponding tool.
- Observe – The result (file content, command stdout, error) is fed back to the model as a new message, prompting it to refine its plan if needed.
The loop terminates when the model responds with an action of type final_output.
Analogy
Imagine a child building a LEGO model with a picture as a guide. The child first looks at the picture (plan), places a few bricks (act), steps back to see if it matches the picture (observe), and repeats until the model is complete. The child doesn’t magically know the final shape; they learn it piece by piece.
Code: orchestrating one iteration
import https from "node:https";
/**
* Send a single request to Cursor's /agent endpoint.
* Returns the JSON body that contains the next action.
*/
async function callAgent(
payload: Record<string, unknown>,
clientId: string
): Promise<any> {
const body = JSON.stringify(payload);
const options = {
hostname: "api.cursor.com",
path: "/v1/agent",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
// The service requires this header, otherwise you get 403.
"X-Cursor-Client-Id": clientId,
},
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
if (res.statusCode !== 200) {
reject(
new Error(`Cursor API error ${res.statusCode}: ${data}`)
);
} else {
resolve(JSON.parse(data));
}
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
Key takeaway: The
X-Cursor-Client-Idheader is mandatory; without it the server answers with a 403 error that looks like an authentication problem.
Building a Minimal CLI with Node.js 22
Why a CLI – Command‑line tools are the quickest way to experiment because they run directly from the terminal and need no extra UI. Node.js 22 adds native support for TypeScript stripping, which means we can ship a single JavaScript file without a separate compilation step.
Required SDKs
| Service | SDK | Known gotchas |
|---|---|---|
| CursorAI |
@cursorhq/sdk (or any thin wrapper you write) |
Must include X-Cursor-Client-Id; the SDK does not add it automatically. |
| NodeJS 22 | Built‑in fs, child_process, https
|
--experimental-strip-types only works on .ts files executed with node. Forgetting the flag leaves type annotations in the output, causing syntax errors. |
Project layout
generate-func.ts # main CLI entry point
package.json # minimal, only for type definitions (optional)
No tsconfig.json needed because we rely on the experimental flag.
Code: CLI skeleton
#!/usr/bin/env node
/**
* generate-func.ts
*
* A tiny CLI that asks Cursor's Agent to write a TypeScript function,
* saves it to disk, runs it, and prints the result.
*
* Run with:
* node --experimental-strip-types generate-func.ts "describe function"
*
* The flag removes all TypeScript type annotations at runtime,
* leaving plain JavaScript that the Node engine can execute.
*/
import { promises as fs } from "node:fs";
import { exec } from "node:child_process";
import { callAgent } from "./agent-client.js"; // the helper from the previous section
// ---- Configuration ---------------------------------------------------------
const CURSOR_CLIENT_ID = process.env.CURSOR_CLIENT_ID; // <-- set this in your env
if (!CURSOR_CLIENT_ID) {
console.error("❗ Set CURSOR_CLIENT_ID environment variable.");
process.exit(1);
}
// The description comes from the command line arguments.
const description = process.argv.slice(2).join(" ");
if (!description) {
console.error("❗ Provide a natural‑language description as argument.");
process.exit(1);
}
// ---- Helper utilities -------------------------------------------------------
/**
* Write the generated code to a temporary file.
*/
async function writeTempFile(content: string): Promise<string> {
const path = "./generated.ts";
await fs.writeFile(path, content, "utf8");
return path;
}
/**
* Execute a TypeScript file (which will be stripped of types at runtime)
* and capture its stdout.
*/
function runGeneratedFile(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(`node ${filePath}`, (error, stdout, stderr) => {
if (error) reject(error);
else if (stderr) reject(new Error(stderr));
else resolve(stdout.trim());
});
});
}
// ---- Main loop -------------------------------------------------------------
async function main() {
// 1️⃣ Start a new session with the user description.
const sessionId = `cli-${Date.now()}`;
let messages = [
{ role: "system", content: "You are a helpful coding assistant." },
{ role: "user", content: description },
];
while (true) {
// 2️⃣ Ask the agent what to do next.
const response = await callAgent(
{
messages,
tools: [
{ type: "file_write", description: "Write a file to the repo." },
{ type: "run_command", description: "Execute a shell command." },
],
session_id: sessionId,
},
CURSOR_CLIENT_ID
);
const action = response.action;
// 3️⃣ If the model says it's done, break out.
if (action.type === "final_output") {
console.log("✅ Final result:", action.payload);
break;
}
// 4️⃣ Otherwise, perform the requested tool.
if (action.type === "file_write") {
const filePath = await writeTempFile(action.payload.content);
// Feed the observation back to the model.
messages.push({
role: "assistant",
content: `Wrote file ${filePath}.`,
});
} else if (action.type === "run_command") {
try {
const out = await runGeneratedFile(action.payload.command);
messages.push({
role: "assistant",
content: `Command output: ${out}`,
});
} catch (e) {
messages.push({
role: "assistant",
content: `Command failed: ${(e as Error).message}`,
});
}
} else {
// Unexpected tool – abort to avoid endless loops.
console.error("❗ Unknown action:", action.type);
break;
}
// 5️⃣ Append the model's thought for context (optional but helpful).
messages.push({
role: "assistant",
content: `Thought: ${response.thought}`,
});
}
}
// Run the program.
main().catch((err) => {
console.error("❌ Unexpected error:", err);
process.exit(1);
});
Tip: Keep the loop short (max 5 iterations) to avoid runaway requests and unnecessary cost.
How the CLI uses native TypeScript stripping
When you launch the script with node --experimental-strip-types generate-func.ts, Node parses the file, removes any : string, : number, interface …, etc., and then executes the resulting pure JavaScript. No separate tsc compile step is needed, which keeps the project size tiny and the developer experience immediate.
In plain English: The flag works like a magic eraser that wipes out the type‑only parts of the file right before the code runs.
Running the Loop with Native TypeScript Stripping
Why strip types – Shipping a TypeScript file to production without stripping will cause a syntax error because the V8 engine doesn't understand type annotations. Stripping lets you keep the convenience of TypeScript while delivering a single runnable file.
Step‑by‑step execution
-
Install Node 22 –
nvm install 22 && nvm use 22. -
Set the required env var –
export CURSOR_CLIENT_ID=your‑client‑id. -
Execute –
node --experimental-strip-types generate-func.ts "Create a function that returns the factorial of n"
The CLI will:
- Send the description to the Agent.
- Receive a
file_writeaction containing a full TypeScript function. - Write the file to
generated.ts. - Run the file (Node strips types on the fly) and capture the output.
- Feed the result back to the Agent, which may ask for a test run or a fix.
- Finally print the function’s return value.
Sample output
Wrote file generated.ts.
Command output: 120
✅ Final result: 120
Key takeaway: The entire plan‑act‑observe cycle happens inside a single process, and the experimental flag guarantees the generated TypeScript never reaches the runtime engine in its original form.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
403 Forbidden from Cursor |
Missing or misspelled X-Cursor-Client-Id header |
Verify the env var and header name |
SyntaxError: Unexpected token ':' |
Ran without --experimental-strip-types
|
Add the flag or pre‑compile with tsc
|
| Agent repeats the same step | Session ID changed between calls | Keep session_id constant for the CLI run |
Testing, Debugging, and Observability
Why test – Because the loop involves network calls and external tools, a deterministic test suite prevents regressions and helps you understand where the Agent may get stuck.
Simple unit test with a mock agent
import assert from "node:assert";
import { callAgent } from "./agent-client.js";
// Mock the HTTPS request by overriding the function (only for tests)
async function mockCallAgent(
payload: Record<string, unknown>,
_clientId: string
) {
// Echo back a final_output after the first request.
if (payload.session_id?.toString().includes("test")) {
return {
action: { type: "final_output", payload: "mocked result" },
thought: "All done.",
};
}
// Default mock response
return {
action: { type: "file_write", payload: { content: "export const x = 1;" } },
thought: "Write a simple file.",
};
}
// Replace the real function with the mock for this test.
const original = callAgent;
(callAgent as any) = mockCallAgent;
// Run a tiny piece of the CLI logic.
(async () => {
const response = await callAgent(
{ messages: [], session_id: "test-session" },
"dummy"
);
assert.strictEqual(response.action.type, "final_output");
console.log("✅ Mock test passed");
// Restore original after test
(callAgent as any) = original;
})();
Tip: Keep the mock simple – you only need to verify that your CLI correctly interprets the
action.typefield.
Logging the loop
Add a tiny logger to the main loop to see the exact conversation:
function logStep(step: string, data: unknown) {
console.debug(`[${new Date().toISOString()}] ${step}:`, data);
}
Call logStep("request", payload) before callAgent and logStep("response", response) after. Using DEBUG=cli node ... lets you toggle verbosity without changing code.
Observability in production
If you ship this CLI inside a CI pipeline, consider emitting metrics:
- iterations – how many plan‑act‑observe cycles were needed.
- latency – round‑trip time to the Cursor API.
- errors – count of 403 or tool execution failures.
These numbers can be sent to any metrics collector (Prometheus, DataDog, etc.) using a simple HTTP POST.
In plain English: Think of logs as the diary of your AI assistant; metrics are the summary you read at the end of the month.
The Takeaway
- Agent Mode is a deterministic loop where the model plans, you act, and the model observes the result.
- The loop needs a stable
session_idand the mandatoryX-Cursor-Client-Idheader; forgetting the header triggers a 403 error. - Node.js 22’s
--experimental-strip-typesflag lets you run generated TypeScript without a separate compile step. - A minimal CLI can orchestrate the whole process with only built‑in Node modules and a tiny helper for the HTTP call.
- Testing with mocks and adding simple logging gives you confidence that the agent behaves as expected, and metrics let you monitor usage in real environments.
Happy coding! 🎉
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-24 · Primary focus: CursorAI
All code blocks are intended to be correct and runnable, but please verify them
against Cursor's official docs before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)