Blog

Cursor's Agent SDK, Hands-On: An AI Agent Is Now a Folder of Code

Cursor's Agent SDK is generally available. I had an agent scaffold a small test project that turns website form submissions into CRM leads, ran the CLI's offline checks, and noted what I'd lock down before letting it near a real business.

Caleb Dorris 26 October 2026, 3:05 PM 14 min

A laptop on a dark desk showing a code editor with a file tree of folders, next to a desk lamp and a small box of index cards.

The short version

It's a real way to ship agents like regular code. Lock down the permissions first.

  • What it is: Cursor's Agent SDK lets you define an agent as a folder of Markdown and TypeScript, test it with evals, and run it on Cursor's hosting or your own server. Cursor announced it as generally available in its October 20 changelog.
  • How it's built: the prompt lives in bot/instructions.md, each file in bot/tools/ is one typed action, and evals live in evals/.
  • What starts it: GitHub, GitLab and Bitbucket pull request, comment and CI events; Slack mentions and DMs; webhooks from any provider; and cron schedules.
  • Install note: the changelog's npx @cursor/agent-sdk command didn't resolve on npm when I tried it. The docs' package, @grok/agent-sdk, did.
  • Before you deploy: tools run with full access to the server's environment by default, so give the agent its own scoped keys, declare its network hosts, and keep writes out of test runs.

Cursor's Agent SDK is generally available. Cursor's pitch, from its announcement on X: "Define agents as code, with prompts in Markdown and tools in TypeScript. Deploy them on our hosted infrastructure, or run them on your own."

I build AI receptionists and integrations for small businesses, so the part I care about is simple. Can I build an agent the way I build everything else, in a Git repo, with tests and code review, and then point it at a real business process? I had an agent scaffold a small test project on a scratch machine and run the SDK's offline checks to find out. The terminal output below is real. The parts that need a Cursor login, like model turns, evals and deploys, I didn't run, and I say so where it matters.

What Cursor shipped

From Cursor's October 20 changelog and the Agent SDK docs:

  • An agent is a project. The changelog says it lets you "define an agent as a folder of code, test it with evals, and deploy it to Cursor or your own infrastructure."
  • Prompt in Markdown, tools in TypeScript. bot/instructions.md is the prompt. Each file in bot/tools/ is one typed tool, and the filename is the name the model sees. There are also folders for skills (in the same SKILL.md convention I wrote about in Agent Skills joining the Agentic AI Foundation), MCP connections, channels, hooks and schedules.
  • Triggers. Agents can wake on "pull request, comment, and CI events from GitHub, GitLab, and Bitbucket; on mentions and DMs in Slack; on webhooks from any provider; and on a cron schedule." GitHub events come through your Cursor account, so you don't need a public webhook URL for those.
  • Evals built in. An eval sends a fixed message to the real agent, then checks which tools ran, what they were given and what the reply looks like. agent-sdk eval runs them locally or in CI.
  • Hosting. agent-sdk deploy runs it on Cursor's hosting, with secrets managed by name. agent-sdk serve runs it on your own server under Node 22.13 or newer.

Cursor says the same harness powers its own Bugbot, Security Review, Approval Bot and Rollouts agents. That's a good sign it's more than a demo framework.

The install snag

The changelog says to run npx @cursor/agent-sdk init ./my-bot. When I tried it on October 26, npm returned a 404 for @cursor/agent-sdk. The docs use a different package name, and that one worked:

npm install --global @grok/agent-sdk
agent-sdk init ./my-bot
cd my-bot
agent-sdk dev

The docs say the package "was @cursor/bdk before," and that older name is also published at the same version, 0.3.0. A few more things to know before you start:

  • It needs Node 22.13 or newer. My scratch machine had Node 20, so I used a separate Node 22 install for the test.
  • It isn't open source. The package's license file says use is subject to Cursor's Terms of Service.
  • Model turns need a Cursor login. Scaffolding, validation and calling a tool directly don't.

Building a small-business agent: website form to CRM

Cursor's examples are mostly pull request reviewers. I wanted something closer to my clients' work: a contact form on a website sends a webhook, the agent pulls out the lead details, skips spam, and upserts the lead into a CRM. Here's what the scaffold gave me, abridged from agent-sdk init ./form-to-crm --json:

{
  "ok": true,
  "created": [
    "package.json", "tsconfig.json",
    "bot/agent.ts", "bot/instructions.md",
    "bot/tools/echo.ts", "bot/hooks/memory.ts",
    "evals/evals.config.ts", "evals/smoke.eval.ts",
    "bot/skills/.gitkeep", "bot/channels/.gitkeep",
    "bot/schedules/.gitkeep", "bot/lib/.gitkeep",
    ...
  ],
  "installed": true,
  "next": ["cd form-to-crm", "agent-sdk login", "agent-sdk dev"]
}

Two things in there caught my eye. The generated bot/agent.ts pinned the model to grok-4.5. And bot/hooks/memory.ts turns on a memory journal: its own comment says "Every session and every user of this agent share one memory journal." That's handy for a coding agent. For an agent that handles customers' names, emails and phone numbers, I'd rather not keep a shared log of every conversation by default, so I deleted that hook, as the comment says to.

Then I replaced the demo files. The prompt in bot/instructions.md stays short, which is what the docs recommend:

# Website lead intake

You turn website contact-form submissions into CRM leads.

- Call `upsert_lead` once per submission, with the email, name, phone,
  and a one-line summary of what the person asked for.
- If the email is missing or obviously fake, don't call the tool. Reply
  with "skipped:" and the reason.
- Never invent details that aren't in the submission.

End with one line: "saved: <email>" or "skipped: <reason>".

The tool is bot/tools/upsert_lead.ts. The Zod schema is the contract, so the model can't pass a malformed email or a 3,000-word summary. The early return keeps the CRM untouched during evals and on any machine without a CRM key:

import { defineTool } from "@grok/agent-sdk/tools";
import { z } from "zod";
import { upsertLead } from "../lib/crm.js";

export default defineTool({
  description: "Create or update one lead in the CRM, keyed by email.",
  inputSchema: z.object({
    email: z.string().email(),
    name: z.string().min(1),
    phone: z.string().optional(),
    summary: z.string().max(280).describe("One line: what they asked for."),
  }),
  async execute(lead, ctx) {
    // Evals and local dry runs make the decision without touching the CRM.
    if (ctx.session?.purpose === "eval" || !process.env.CRM_API_KEY) {
      return { saved: false, reason: "dry-run", wouldSend: lead };
    }
    const { id } = await upsertLead(lead);
    return { saved: true, id };
  },
});

upsertLead lives in bot/lib/crm.ts, which the SDK doesn't treat as a tool. In my test it's a placeholder fetch to crm.example.com. In a real build, you'd swap in your CRM's upsert endpoint.

The webhook: a signed channel

A file in bot/channels/ becomes a set of HTTP routes. This one accepts the form post at /v1/channels/website-form/submit, but only if the body is signed with a shared secret. On Cursor's hosting, a bad signature gets a 401 before the agent ever sees it:

import { defineChannel, hmacSignatureAuth, POST } from "@grok/agent-sdk/channels";
import { z } from "zod";

export default defineChannel({
  auth: [hmacSignatureAuth({ header: "x-form-signature", secret: "FORM_WEBHOOK_SECRET", prefix: "sha256=" })],
  routes: [
    POST("/submit", {
      description: "A contact-form submission from the website",
      bodySchema: z.object({
        submissionId: z.string(),
        name: z.string(),
        email: z.string(),
        phone: z.string().optional(),
        message: z.string().max(5000),
      }),
      handler: async (_request, { send, waitUntil, body }) => {
        waitUntil(
          send("Save this website lead.", {
            title: `Form ${body.submissionId}`,
            workspaceFiles: { "submission.json": JSON.stringify(body, null, 2) },
          })
        );
        return new Response(null, { status: 202 });
      },
    }),
  ],
});

A few details from the docs that matter for real webhooks:

  • secret is the name of a secret, not the value. Locally it reads that environment variable. On Cursor's hosting it reads the deployment secret with that name.
  • On Cursor's hosting, the caller always gets an empty 202 right away, whatever the handler returns. Results have to go back through an API call, an event handler or a tool. That's why the CRM write is a tool.
  • Hosting doesn't deduplicate retries, so a retried delivery runs a second turn. Upserting by email instead of always creating a new lead keeps a retry from making a duplicate.
  • I pass the form as a file in the workspace rather than pasting it into the prompt. The model reads it as data, which helps a little against someone typing instructions into your contact form. It isn't a complete defense, which is one more reason the tool's schema and permissions matter.

A nightly check with cron

For the nightly check, I added a second tool that only reads. bot/tools/check_site.ts makes one request to the site's contact page and reports what came back:

import { defineTool } from "@grok/agent-sdk/tools";
import { z } from "zod";

// Read-only: one GET to the site's own contact page.
export default defineTool({
  description: "Check that the website's contact page loads. Read-only.",
  inputSchema: z.object({}),
  async execute() {
    const started = Date.now();
    const res = await fetch("https://www.example.com/contact/", { redirect: "follow" });
    return { status: res.status, ms: Date.now() - started };
  },
});

The simplest schedule is a Markdown file in bot/schedules/ with a cron: line at the top. Each run starts a fresh session with the file's text as the prompt:

---
cron: "0 11 * * *"
---

Call check_site. If the status isn't 200, reply "ALERT:" with the status.
Otherwise reply "ok" with the response time. Don't call upsert_lead.

Schedules run in UTC, so 0 11 * * * is 6 AM here in Nashville during daylight saving time and 5 AM after the clocks fall back. On Cursor's hosting, a scheduled run has to finish in 26 minutes, and a deploy can hold up to 64 schedules. In local dev mode, schedules only run when you trigger them by hand.

A Markdown schedule runs and finishes without anyone reading the reply, so the answer ends up in the session log. To get an alert in front of a person, add a tool that sends it to Slack or email, or use a schedule written in TypeScript that hands the work to a channel. The same pattern works for the other nightly checks I'd usually write as a cron job, like whether the form sent anything today or a plugin is out of date.

What I actually ran

None of these commands needed a Cursor login. First, validation, which checks the whole project:

$ agent-sdk validate --dir .
ok: form-to-crm (form-to-crm) has no diagnostics

$ agent-sdk info --dir .
1 agents under .:

  /form-to-crm  —  form-to-crm  ({"id":"grok-4.5","params":[{"id":"effort","value":"high"},{"id":"fast","value":"true"}]}, 2 tools, 2 channels)

The two channels are mine plus the built-in HTTP API that every agent gets. agent-sdk info --json also listed the nightly-site-check schedule and, under hosting, the exact network hosts and secret names the agent declares. The model still shows grok-4.5 after I removed the model block from agent.ts, so that looks like the default in this version.

Then I called the tool directly, with no model involved. The docs say this needs no Cursor API key, and it didn't:

$ agent-sdk tool call upsert_lead --dir . \
    --input '{"email":"dana@example.com","name":"Dana Example","summary":"Wants a quote next week"}'
{
  "saved": false,
  "reason": "dry-run",
  "wouldSend": {
    "email": "dana@example.com",
    "name": "Dana Example",
    "summary": "Wants a quote next week"
  }
}

$ agent-sdk tool call upsert_lead --dir . \
    --input '{"email":"not-an-email","name":"x","summary":"y"}'
agent-sdk tool call: Invalid input for tool "upsert_lead": email: Invalid email address

That second result is the point of typed tools. A bad value is rejected before your code runs, so it never reaches the CRM.

The site check made a real request. My placeholder site, www.example.com, has no contact page, so it shows what the alert case looks like:

$ agent-sdk tool call check_site --dir . --input '{}'
{
  "status": 404,
  "ms": 91
}

The project also passed its own TypeScript check (npm run check).

Evals: tests for the agent's judgment

Unit tests check your code. Evals check the model's decisions. This one sends a real-looking lead and a spam submission, then asserts the agent saved the first and skipped the second:

import { defineEval, includes } from "@grok/agent-sdk/evals";

export default defineEval({
  tags: ["smoke"],
  description: "A real submission becomes one upsert; spam is skipped.",
  async test(t) {
    const saved = await t.send("Save this website lead.", {
      workspaceFiles: {
        "submission.json": JSON.stringify({
          submissionId: "test-1",
          name: "Dana Example",
          email: "dana@example.com",
          message: "Can someone call me about a quote next week?",
        }),
      },
    });
    saved.calledTool("upsert_lead");
    t.check(saved.message, includes(/saved: dana@example\.com/));

    const spam = await t.send("Save this website lead.", {
      workspaceFiles: { "submission.json": JSON.stringify({ submissionId: "test-2", name: "x", email: "", message: "cheap seo!!!" }) },
    });
    spam.notCalledTool("upsert_lead");
    t.succeeded();
  },
});

Listing it works offline:

$ agent-sdk eval --dir . --list
lead-intake  [smoke] — A real submission becomes one upsert; spam is skipped.

Running it sends real model turns, so it needs a Cursor credential, and I didn't run it in this test. The docs are clear that evals run your real tools, which is why the tool checks ctx.session.purpose === "eval" and skips the CRM write. The eval still sees that the agent chose to call the tool, and that's what you're testing.

This is the same idea I wrote about in Cursor agents that test what they build: don't trust an agent's work until something checks it. Here, you write that check before the agent ever touches a client's CRM.

Least privilege before you deploy

This is the part I'd slow down on. The tools docs say a server tool, which is the default kind, runs "in-process on the serving host with full access to process.env." Any secret in that environment is reachable by every tool. Here's how I'd set it up:

  • Give the agent its own CRM user and API key, limited to creating and updating leads. Not an admin key, and not the owner's login.
  • Declare every host and secret name in bot/agent.ts (shown below). agent-sdk info shows the combined list, which makes it easy to review in a pull request. Commit secret names, never values. The values go in with agent-sdk secret set after you deploy.
  • Sign every webhook. Use hmacSignatureAuth or sharedSecretAuth, and only use publicEndpoint() if your handler checks the provider's signature itself.
  • Keep writes out of test runs with the eval check above, and keep a dry-run mode for local work.
  • Think before you turn on shared memory for anything that handles customer data.
  • Be careful with approvals. Tools have a needsApproval option that pauses a call until a person approves it, but the package's type definitions in version 0.3.0 say it's only supported for server tools on the local runtime. I wouldn't count on it in a hosted deployment without testing it.
  • Rotate the alias token if it leaks. The first deploy prints the deployment's alias token once, and agent-sdk deployment rotate-token replaces it.
import { defineAgent } from "@grok/agent-sdk";

export default defineAgent({
  hosting: {
    // Only the hosts and secret names this agent actually needs.
    egressDomains: ["crm.example.com", "www.example.com"],
    secretNames: ["CRM_API_KEY", "FORM_WEBHOOK_SECRET"],
  },
});

I covered the general version of this in scoping AI agent permissions. The Agent SDK makes it easier, because the permissions are written down in files you can review.

Cursor's hosting or your own server?

Per the deployment docs, agent-sdk deploy builds a commit you've already pushed, so uncommitted changes don't ship, and it waits until the agent is running. Secrets are set per deployment. If your team limits outbound network access, deploys fail on any host the policy blocks. If you use Cursor's Privacy Mode (Legacy) setting, deploys, hosted sessions and eval runs are refused.

Self-hosting is agent-sdk serve under Node 22.13 or newer, with a Cursor API key for model turns and a bearer token for callers. The docs say to put TLS or an authenticating proxy in front of it and to keep state on a backed-up volume. For most small businesses I'd start on Cursor's hosting. I'd self-host when a client's data has to stay on their own network.

I didn't find hosting prices in the Agent SDK docs, so check what your Cursor plan covers before you deploy anything that runs all day.

Where it fits

I wrote recently about how fast the agent tools are changing. The Agent SDK is the first one that feels like it was built for the way I already work. The agent is a repo. Changes go through pull requests. The tests run in CI. The permissions are a list you can read.

For a one-off task, a hosted automation or a chat agent you configure in an app is still faster. For something that touches a business's leads, calendar or customers every day, I want the code, the tests and the permissions in one place, and that's what this gives you.

If you have a form, inbox or spreadsheet that someone copies into another system by hand, get a free quote. I'll tell you whether an agent is the right fit or a plain integration would do the job.

FAQ

What is Cursor's Agent SDK?

A framework for defining an AI agent as a folder of Markdown and TypeScript: a prompt in instructions.md, typed tools in tools/, and evals in evals/. You can run it on Cursor's hosting or on your own server. Cursor announced it as generally available on October 20, 2026.

How do I install it?

The docs say npm install --global @grok/agent-sdk, then agent-sdk init ./my-bot. You need Node 22.13 or newer. The changelog's npx @cursor/agent-sdk command returned a 404 from npm when I tried it on October 26.

What can trigger an agent?

Pull request, comment and CI events from GitHub, GitLab and Bitbucket; Slack mentions and DMs; webhooks from any provider; and cron schedules in bot/schedules/.

Can I try it without a Cursor account?

Partly. Scaffolding, agent-sdk validate, agent-sdk info, listing evals and calling a tool directly worked without logging in. Model turns, running evals and deploying need a Cursor credential.

Is it safe to connect to a CRM?

It can be, if you treat it like any integration. Give it its own limited API key, declare its network hosts and secrets, sign every webhook, keep writes out of evals, and remember that server tools can read the whole environment by default.

Sources

Need a web developer in Nashville?

PHP, WordPress and Next.js. Free quote, and I usually reply within a couple of hours.

caleb@calebdorris.com and call or text (615) 914-1073

All posts