@cursor/july
v0.1.18
Published
(early alpha) Filesystem-first framework for defining Cursor agents as markdown and TypeScript and serving them over channels with the Cursor SDK.
Maintainers
Keywords
Readme
@cursor/july
[!WARNING] Early alpha.
@cursor/julyis the codename release of Cursor's agent framework (the CLI isagentkit). It is under active development: APIs, the CLI surface, and the package name itself will change before a stable release, and 0.x versions may ship breaking changes without notice.
A filesystem-first framework for building and serving Cursor agents.
Customers define an agent as ordinary files — markdown for prose,
TypeScript for typed behavior — under an agent/ directory. The framework
discovers those files, compiles them into a manifest, and serves the agent
over channels, using the Cursor SDK (@cursor/sdk) and the Cursor harness
as the execution engine.
User-facing documentation lives in docs/ —
also served at /docs by every running agent-serve serve host —
getting started, concepts, guides (Slack, GitHub webhooks, approvals,
agent-to-agent, cloud runtime), evals, live A/B metrics, hillclimbing,
deployment, troubleshooting, and reference pages for each folder. This
README is the compact package reference.
Instead of one large configuration object, each part of the agent gets a clear home. Instructions go in one file, tools in one folder, channels in another; a file's location says what it does, and its path gives it its name. There is no registry to keep in sync: add the file and it is discovered, move or rename it and its identity moves with it.
my-agent/
├── package.json
└── agent/
├── agent.ts # runtime config: the Cursor model
├── instructions.md # the always-on system prompt
├── tools/
│ └── get_weather.ts # one typed tool per file
├── skills/
│ └── forecast.md # on-demand procedures (SKILL.md convention)
├── mcp-connections/
│ └── linear.ts # tools from external MCP servers
├── subagents/
│ └── researcher/ # specialist child agents
├── channels/
│ └── webhook.ts # HTTP surfaces beyond the built-in session API
├── hooks/
│ └── audit.ts # observe the runtime event stream
├── ab.ts # optional live A/B experiment
├── ab/ # optional: more experiments
├── schedules/
│ └── heartbeat.md # cron-driven runs
├── sandbox/workspace/ # files seeded into each session's workspace
└── lib/ # shared code (import-only, never discovered)Scaffold a starter project with agent-serve init ./my-agent — a minimal
agent (package.json, tsconfig.json, agent/agent.ts,
agent/instructions.md, and a demo agent/tools/echo.ts) that serves
immediately and type-checks with npm run check. From there, grow it folder
by folder; the docs walk a weather agent from a single
get_weather tool through skills, channels, evals, and deployment.
Serve one project with agent-serve serve --dir ./my-agent --dev, or point
serve at a folder of agent projects to mount every child under its
directory name.
Node only — do not run under Bun
Run agent-serve with Node 22+ (from source: pnpm exec tsx
src/bin/agent-serve.ts …, or the built dist/bin/agent-serve.js). Do not
run it under Bun: Bun's HTTP/2 client corrupts the Cursor SDK's local
harness tool-result streams (NGHTTP2_FRAME_SIZE_ERROR), so every built-in
read/grep the model makes fails and turns degrade into failed-read retry
loops (we measured an 8-minute review that takes ~1 minute under Node).
The mise tasks in this package already use tsx.
Serving many agents at once
Point serve at a folder of agent projects and it hosts all of them on
one port, each under its own slug (its directory name):
agent-serve serve --dir ./agents --dev
# 2 agents listening
# playground http://127.0.0.1:5273serve always uses multi-agent layout by default: agents are mounted under
/<slug>/v1/* with an index at /. A directory that is itself an agent
project is mounted under its directory name. Opt into the unslugged
/v1/* surface with serve(dir, { mode: "single" }).
In multi-agent mode:
GET /is a web index listing every agent, linking to its playground;GET /v1/agentsis the JSON equivalent.GET /v1/healthis host-level liveness (for ALB/ECS); each agent also has/<slug>/v1/health.- each agent is fully namespaced:
/<slug>/v1/session,/<slug>/v1/session/:id/stream,/<slug>/playground,/<slug>/v1/info, custom channels at/<slug>/v1/channels/<id>, etc. - sessions are isolated per agent (
<stateRoot>/<slug>/), and the playground gains a "← all agents" link back to the index. - point the terminal client at a slug:
agent-serve chat --url http://127.0.0.1:3000/weather-agent.
Slugs come from directory names and must match [A-Za-z0-9][A-Za-z0-9_-]*
and not collide with the reserved v1 / playground / docs path
segments.
Agent-to-agent: every agent is an MCP server
Every mounted agent also serves the Model Context Protocol over
streamable HTTP at /<slug>/v1/mcp (or /v1/mcp in single mode), so other
agents — and any MCP client — can delegate work to it. The surface is
stateless (session identity travels in tool arguments) and runs the same
route auth chain as the session API. Tools:
| Tool | Behavior |
| ---- | -------- |
| ask | Send a message; runs a model turn in this agent's own session, tools, and context and returns { status, sessionId, reply }. Omit sessionId for a fresh session; pass it to follow up. |
| check | Wait for / poll a running session (waitSeconds: 0 for a non-blocking poll). |
| call_tool | Call one of the agent's deterministic server tools directly (no model turn). Registered only when the agent has server tools. |
Waits are bounded (~50s, below MCP client request timeouts): a long turn
returns status: "running" and the caller keeps waiting with check.
Sessions created this way live on the mcp channel, bind to the calling
principal, and show up in the playground and GET /v1/sessions like any
other session.
Peer MCP connections make delegation first-class between agents on the same host. Author an MCP connection whose transport is a peer slug:
// agents/concierge/agent/mcp-connections/weather.ts
import { defineConnection } from "@cursor/july/connections";
export default defineConnection({
agent: "weather-agent",
description: "Delegate weather questions to the weather agent.",
});The parent model then sees the peer's ask / check (/ call_tool) tools
under the weather server name — subagent-style delegation where the peer
keeps its own instructions, tools, MCP connections, and sessions. Peer URLs
resolve when the server starts (so run / eval ephemeral ports work):
- Local-runtime turns (and host-side
ctx.host.mcp/ channel handlers) call the peer over loopback — works out of the box under the defaultlocalDevStrict()auth. - Cloud-runtime turns execute on a cloud VM that cannot reach this
host's loopback address. Pass
--public-url https://agent-serve.example.com(orserve(dir, { publicUrl })) so peers resolve to a reachable URL; without it, peers are omitted from cloud turns (the server warns at startup). With--bearer-token, the token is attached to peer calls automatically so they pass the target agent's auth chain.
Unknown peer slugs and self-references fail at serve startup. Peers require
the multi-agent layout (each agent mounted under its slug). There is no
cross-host loop protection yet: if agent A's instructions delegate to B and
B's delegate back to A, they can recurse — scope each agent's delegation
instructions narrowly (the concierge above delegates weather questions to
weather-agent, not everything).
agent-serve serve --dir ./agents --dev
agent-serve chat --url http://127.0.0.1:3000/concierge \
--message "What's the weather in Paris right now?"
# concierge → weather.ask → weather-agent's own session/tools → replyQuick start
A minimal agent is two files.
agent/instructions.md:
You are a concise assistant. Use tools when they are available.agent/agent.ts:
import { defineAgent } from "@cursor/july";
export default defineAgent({
// optional — defaults to grok-4.5 with effort=high and fast=true
// runtime: "local", // default — Cursor SDK local harness
// runtime: "cloud",
// cloud: {
// repos: [{ url: "https://github.com/org/repo", startingRef: "main" }],
// },
});Serve it (turns run on the Cursor harness, so the host needs a Cursor credential — sign in once, or export an API key):
agent-serve login # browser sign-in; mints + stores a revocable API key
# or: export CURSOR_API_KEY=key_...
agent-serve serve --dir . --port 3000
# e.g. playground at http://127.0.0.1:3000/<dirname>/playgroundStart a session, then follow the NDJSON event stream (replace <slug> with
the agent directory name):
curl -X POST http://127.0.0.1:3000/<slug>/v1/session \
-H 'content-type: application/json' \
-d '{"message":"What can you do?"}'
# {"ok":true,"sessionId":"ses_...","continuationToken":"http:..."}
curl -N http://127.0.0.1:3000/<slug>/v1/session/ses_.../stream
# {"type":"session.started",...}
# {"type":"message.appended","data":{"delta":"I can","text":"I can"},...}
# {"type":"message.completed",...}
# {"type":"turn.completed",...}
# {"type":"session.waiting",...}Send a follow-up to the same durable session with the continuation token from the previous response (each accepted follow-up rotates it):
curl -X POST http://127.0.0.1:3000/<slug>/v1/session/ses_... \
-H 'content-type: application/json' \
-d '{"continuationToken":"http:...","message":"Shorter, please."}'Inspect the discovered surface at any time:
agent-serve info --dir . # or GET /<slug>/v1/info on a running server
agent-serve validate --dir . # exit non-zero on error diagnosticsTerminal client
agent-serve chat talks to a running server over the same public API and
renders the reply live — streamed text, tool calls, and a per-turn usage
footer. Pass --json for a compact trajectory (same shape as run).
# interactive REPL against a local server
agent-serve chat --url http://127.0.0.1:3000
# one-shot (good for piping / scripts)
agent-serve chat --url http://127.0.0.1:3000 --message "Weather in Paris?"
# JSON trajectory (for coding agents / scripts)
agent-serve chat --url http://127.0.0.1:3000 --message "hello" --json
# against an authed server
agent-serve chat --url https://my-agent.example.com --bearer-token "$TOKEN"Agent loop
Coding agents should prefer the JSON-first commands: edit files → inspect → run a turn → assert with filesystem evals.
agent-serve validate --dir .
agent-serve info --dir . --json
# Ephemeral server + one (or more) turns; JSON trajectory on stdout
agent-serve run --dir . --message "What's the temperature in NYC?"
# Optional event log for later summarization
agent-serve run --dir . --message "..." --events /tmp/run.ndjson
agent-serve trajectory --events /tmp/run.ndjson
# Scaffold (or print the setup guide with no directory)
agent-serve init ./my-agent
agent-serve initrun / eval boot a temporary server on port 0 with a temp stateRoot
outside the project so session workspaces do not inherit ambient monorepo
AGENTS.md / .cursor rules. Pass --url to target an already-running
agent instead.
Evals
Author cases under evals/**/*.eval.ts with defineEval. Drive the
agent and assert inline:
import { defineEval, includes } from "@cursor/july/evals";
export default defineEval({
description: "Uses get_weather for temperature questions.",
tags: ["smoke"],
async test(t) {
await t.send("What's the temperature in NYC?");
t.succeeded();
t.calledTool("get_weather");
t.check(t.reply, includes(/°|[FC]/));
},
});Case id is the path under evals/ for a single-test file, or
<fileId>/<case.id> when using cases in one file.
Every project with evals needs evals/evals.config.ts with a required
maxConcurrency (hard-capped at 200 due to model provider request limits).
Playground batches are in-memory by default; set optional persistRuns to
keep them across serve restarts. Optional maxPlaygroundRuns caps how many
batches the playground / /v1/dev/evals* history keeps (default 20):
import {
defineEvalConfig,
persistEvalRunsToDir,
} from "@cursor/july/evals";
export default defineEvalConfig({
maxConcurrency: 20,
// Optional — playground /v1/dev/evals history window (default 20):
// maxPlaygroundRuns: 50,
// Optional — survive serve restarts (omit to keep runs in memory only):
persistRuns: persistEvalRunsToDir(".agent-serve/eval-runs"),
});agent-serve eval --dir . --list
agent-serve eval --dir . --json
agent-serve eval --dir . weather/nyc # one datapoint
agent-serve eval --dir . weather # whole file
agent-serve eval --dir . weather forecast # multiple files
agent-serve eval --dir . --tag smokeSee docs/ for the full documentation set,
AGENTS.md for the coding-agent guide, and
skills/ for task-shaped guides (create an agent, author
evals, hillclimb, GitHub webhooks, Slack setup, debugging).
Web playground
Every served agent ships with a built-in playground at
http://127.0.0.1:3000/playground — a Vite + React SPA (reusing
Cursor's internal UI components) over the same public HTTP API, made for quick
manual testing and demo recordings:
- chat with the agent and watch text/reasoning stream live, rendered as markdown (headings, lists, tables, blockquotes, links) with syntax highlighting for fenced code blocks (C-like languages, Python, shell, and diffs),
- invoke custom channels as slash commands in the composer (e.g. a
/drive https://github.com/org/repo/pull/1channel route) — routes fromGET /v1/info, with/helpand autocomplete; same HTTP as the Agent surface Try buttons, - see tool calls inline (args, output, error state) as
actions.requested/action.resultevents arrive, - browse every session (chat, custom-channel, and schedule task sessions) and replay their durable event streams,
- dispatch schedules by hand in dev mode,
- inspect the discovered agent surface (tools, skills, subagents, MCP connections, channels, hooks),
- flip on the raw NDJSON pane to see the exact wire events.
The SPA is a static bundle. agent-serve serve auto-builds dist/playground/
when it is missing and the local vite toolchain is present (pnpm run build
also emits it for publish). The server serves the bundle and every call it
makes runs the normal route auth chain (there's a bearer-token field for
non-loopback setups). Disable it with --no-playground (CLI) or
serve(dir, { playground: false }). When serving many agents, each has its
own playground at /<slug>/playground and / is an index of them all (see
"Serving many agents at once").
Developing the playground
serve --dev / dev also starts Vite HMR (default :5273) and prints that
URL as playground. Single-agent proxies /v1 to the serve URL; multi-agent
serves the agents index at / and each SPA at /<slug>/playground:
# single agent — auto-build + HMR in one process
pnpm exec tsx src/bin/agent-serve.ts serve --dir ./my-agent --dev
# monorepo dev: multi-agent HMR
mise //packages/agent-serve:start
# → backend :3000, playground HMR :5273 (open /, then /<slug>/playground)
# pin one slug at the HMR root, or UI-only against an already-running serve
AGENT_SERVE_BASE=/my-agent mise //packages/agent-serve:start
AGENT_SERVE_MULTI=1 mise //packages/agent-serve:dev-playgroundThe Vite dev server proxies API calls to the backend (AGENT_SERVE_TARGET,
default http://127.0.0.1:3000). Multi-agent HMR proxies /<slug>/v1/* as-is
and serves each SPA at /<slug>/playground; a single-slug pin uses
AGENT_SERVE_BASE=/<slug> at the HMR root. Editing anything under
playground/src hot-reloads in the browser. The playground source lives in
playground/ (entry playground/src/main.tsx); markdown rendering and the
trace model are plain modules under playground/src/lib with unit tests.
To share the server beyond localhost (a tunnel, a LAN address, a phone),
pass --bearer-token <secret> (or serve(dir, { authToken })). That
replaces the loopback-only default with bearerAuth(secret) on every
channel that doesn't author its own auth. The default
localDevStrict() only admits direct loopback callers, rejects
proxy-forwarding headers (X-Forwarded-For, X-Real-IP, Forwarded,
X-Forwarded-Host), and requires a loopback Host header, so
same-host reverse proxies / tunnels do not accidentally re-expose the
default routes. Open the playground on the remote device and paste the
token into the top-right field.
Session follow-up, stream, and list routes also bind to the principal that
created the session (403 when a different admitted principal addresses
someone else's handle). Session ids are restricted to a single safe path
segment before they touch <stateRoot>/sessions.
How it runs on the Cursor harness
Every session is one Cursor SDK agent (Agent.create / Agent.resume).
Local agents use the session id as the SDK agent id; cloud agents persist
a separate sdkAgentId (typically bc-…). Each session gets its own
workspace directory, materialized from the authored files and handed to
the local harness as its working directory:
| Folder or file | Runtime mapping |
| ------------------------ | ------------------------------------------------------------------------------- |
| instructions.* | Local: AGENTS.md in the session workspace. Cloud: prepended to the first prompt. |
| tools/*.ts (execution: "server", default) | Local only — in-process SDK custom tools. Not available on cloud. |
| tools/*.ts (execution: "agent") | Local: scripts under .agent-serve/tools/ + catalog in AGENTS.md. Cloud: catalog + script bodies on the first prompt. |
| skills/* | Local: .cursor/skills/<name>/SKILL.md in the workspace. Cloud: only if present in the cloud repo. |
| mcp-connections/*.ts | Always three places: Cursor agent via SDK mcpServers (local + cloud), host-side ctx.host.mcp for in-process tools, and args.host.mcp on channel/schedule handlers. |
| subagents/<id>/ | SDK custom subagents (the model delegates via the harness task tool) |
| sandbox/workspace/** | Local session workspace seed on first turn; ignored for cloud runtime. |
| channels/, schedules/, hooks/ | Served by this framework around the harness |
Conversation state for local agents persists through the SDK's local store
under .agent-serve/runner/, and every session's event stream is recorded
to .agent-serve/sessions/<id>/events.ndjson — sessions survive server
restarts, and streams replay from any startIndex.
Folder structure
agent.ts
import { defineAgent } from "@cursor/july";
export default defineAgent({
model: {
id: "grok-4.5",
params: [
{ id: "effort", value: "high" },
{ id: "fast", value: "true" },
],
}, // optional; this is the default
runtime: "local", // default — or "cloud"
// cloud: {
// repos: [{ url: "https://github.com/org/repo", startingRef: "main" }],
// env: { type: "cloud" },
// },
});model accepts a Cursor model id string or { id, params }. When omitted
on the root agent, agent-serve defaults to grok-4.5 with effort=high
and fast=true.
runtime selects where turns execute:
| Value | Behavior |
| --------- | ------- |
| "local" | Cursor SDK local harness on this machine. Session id doubles as the SDK agent id. Authored tools, skills, and sandbox seeds apply. |
| "cloud" | Cursor cloud agents. Pass a cloud block (repos, env, envVars, …) forwarded to the SDK. In-process server tools are not available; instructions and agent-tool catalogs are prepended to the first prompt because the local session workspace is not the cloud VM. |
Discovery warns when runtime: "cloud" is combined with server tools, skills, or sandbox seeds that only apply locally.
Instructions
agent/instructions.md is the always-on system prompt (required on the
root agent). When the prompt needs to be generated, use
agent/instructions.ts with defineInstructions({ markdown }) or a plain
string default export, or split prose across an agent/instructions/
directory (composed in filename order).
Tools (agent/tools/*.ts)
One file per tool; the filename is the tool name the model sees.
execution chooses where the tool body runs:
| Value | Behavior |
| ----- | -------- |
| "server" (default) | In-process on the agent-serve host via Cursor SDK custom tools. Requires execute. Only available when the agent runtime is "local". |
| "agent" | Materialized into the agent environment as a shell script. Requires script (JSON on stdin, result on stdout). Works with local and cloud agent runtimes. |
Server tool (default):
import { defineTool } from "@cursor/july/tools";
import { z } from "zod";
export default defineTool({
description: "Get the current weather for a city.",
// execution: "server", // default
inputSchema: z.object({ city: z.string() }),
async execute({ city }, ctx) {
return { city, condition: "Sunny", temperatureF: 72 };
},
});Human-in-the-loop: set needsApproval: true (or an
(input) => boolean predicate on validated args) so the host parks the in-flight
tool call until a human approves or denies. The turn stays running; the
stream emits action.approval_requested / action.approval_resolved.
Resolve surfaces (park is always on when needsApproval is set):
- Playground Approve / Deny buttons
- HTTP:
POST /v1/session/:sessionId/approvals/:callIdwith{"decision":"approve"|"deny"} - Slack (opt-in channel surface) — set
toolApprovals: trueonslackChannel, and enableinteractivityin the Slack app manifest:
import { slackChannel } from "@cursor/july/channels/slack";
export default slackChannel({
credentials: { /* … */ },
toolApprovals: true, // posts Block Kit Approve/Deny + routes clicks
});Or compose the builders yourself:
import {
buildDefaultEvents,
buildToolApprovalEvents,
slackChannel,
} from "@cursor/july/channels/slack";
export default slackChannel({
deliverDefaults: false,
// Required so Socket Mode routes Approve/Deny clicks when composing
// buildToolApprovalEvents by hand (toolApprovals: true also enables this).
interactivity: true,
events: {
...buildDefaultEvents({ credentials }),
...buildToolApprovalEvents({ credentials }),
},
});In --dev, the playground can list and stream Slack sessions, and Approve/Deny
parked tools on those sessions (audit by records the HTTP caller). That bridge
does not open cross-owner approval for HTTP sessions. Production and
bearer-auth hosts stay strict: Slack resolve must come from Slack interactivity
(or a matching principal).
--allow-anonymous is for trusted-network demos only — every HTTP caller shares
the same anonymous principal. Prefer --bearer-token when the host is shared.
Slack cards show redacted / truncated args for Block Kit limits; execution still uses the full validated tool input. Review sensitive tools in the playground or a private surface when args may exceed the card.
Example tool:
export default defineTool({
description: "Post a weather alert to ops.",
needsApproval: true,
inputSchema: z.object({
city: z.string(),
message: z.string(),
}),
async execute({ city, message }) {
return { posted: true, city, message };
},
});Approvals are only supported for execution: "server" tools on the
local runtime. Exact resume of a parked SDK tool call does not
survive host process restart — pending approvals left after a crash are
treated as interrupted.
Agent tool (runs where the Cursor agent runs — local harness or cloud VM):
import { defineTool } from "@cursor/july/tools";
import { z } from "zod";
export default defineTool({
description: "Echo a message from the agent workspace.",
execution: "agent",
inputSchema: z.object({ message: z.string() }),
script: `#!/usr/bin/env bash
set -euo pipefail
message=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["message"])')
printf '%s\\n' "$message"
`,
});inputSchema is a zod schema (validated and typed for server tools) or a
plain JSON Schema object (forwarded as-is). For server tools, ctx carries
{ toolCallId, session, workspaceDir }. Return a string, a JSON value, or
{ content: [...], isError? } for rich results.
Deterministic tool calls
Server tools can also be called deterministically — you pick the tool
and the input, no model turn decides anything. The input is validated
against the tool's schema and execute runs in-process; the result comes
back exactly as the model would receive it. No Cursor API key is needed.
Over HTTP (POST /v1/tools/:toolName, same auth chain as the session API):
curl -X POST http://127.0.0.1:3000/<slug>/v1/tools/get_weather \
-H 'content-type: application/json' \
-d '{"input":{"city":"NYC"}}'
# {"ok":true,"toolName":"get_weather","callId":"tool_get_weather_...",
# "isError":false,"result":{...},"durationMs":12}From the CLI (boots an ephemeral server unless --url targets a running
one):
agent-serve call get_weather --dir . --input '{"city":"NYC"}'
agent-serve call get_weather --url http://127.0.0.1:3000/<slug> --input '{"city":"NYC"}'Programmatically, callTool(toolName, input, options?) is available on the
serve handle, on channel route handlers and onStart args, and on schedule
run handlers — so a channel can mix deterministic tool calls with model
turns (e.g. fetch PR metadata deterministically, then send() the review
prompt):
const outcome = await handle.callTool("get_weather", { city: "NYC" });
// { toolName, callId, isError, result, durationMs }By default the tool runs against an ephemeral scratch workspace under
<stateRoot>/tool-calls/<callId> with a synthetic direct session context —
materialized like a session workspace (AGENTS.md, skills, seed files) and
removed once the call returns. Pass sessionId (body field over HTTP,
--session on the CLI, options.sessionId programmatically) to run it
inside an existing session instead: the tool sees that session's info
and materialized workspace, and the call is recorded on the session's event
stream as actions.requested / action.result under a per-call turnId —
visible in the playground, NDJSON trace, and trajectories like any
model-initiated call. Session-bound calls are serialized with model turns:
while a turn is running the call is rejected with 409 session_busy.
Unknown tools are rejected with the available tool names, execution:
"agent" tools cannot be called on the host (400), schema-invalid input is a
400 before the tool body runs (zod schemas validate; plain JSON Schema
inputs pass through unvalidated, matching the model path), and a tool body
that throws is reported as isError: true with the same
{ content, isError } envelope the model would see.
Skills (agent/skills/*)
Skills follow the SKILL.md convention: model-loadable procedures the
harness advertises by description and loads on demand. Author them as flat
markdown (skills/forecast.md, optional description frontmatter — the
first body line is the fallback), packaged directories
(skills/research/SKILL.md plus references/…, which require description
frontmatter), or TypeScript (defineSkill from
@cursor/july/skills) when content must be generated.
MCP connections (agent/mcp-connections/*.ts)
import { defineConnection } from "@cursor/july/connections";
export default defineConnection({
url: "https://mcp.linear.app/mcp",
headers: { authorization: `Bearer ${process.env.LINEAR_TOKEN}` },
});The filename becomes the MCP server name. { command, args, env, cwd }
declares a local stdio server instead, and { agent: "<slug>" } declares a
peer MCP connection to another agent mounted on the same serve host (see
"Agent-to-agent: every agent is an MCP server").
{ cursorAccount: true } declares a Cursor account MCP connection: the
agent gets the MCP connectors the signed-in Cursor account already
authorized (dashboard → MCP) with zero token plumbing. Every tool executes
on the Cursor backend with the account's stored OAuth credentials; raw
tokens never reach the serve host, session workspaces, or traces. Omit
servers, or pass servers: "*", to forward every connected HTTP/SSE
connector. Pass servers: ["Linear", ...] to expose only named connectors.
// agent/mcp-connections/cursor.ts: every connected connector
export default defineConnection({ cursorAccount: true });
// same, spelled out:
export default defineConnection({ cursorAccount: true, servers: "*" });
// only Linear:
export default defineConnection({ cursorAccount: true, servers: ["Linear"] });Requirements and behavior:
- The host must be signed in (
agent-serve loginorCURSOR_API_KEY);servefails fast at startup otherwise, and logs each connector's live status (connected/needsAuth/error) as it starts. - Local turns and host-side calls go through a loopback bridge route guarded
by a per-boot secret. Cloud-runtime turns reach the same bridge through the
serve
--public-url(soserversfilters apply there too); without one, cloud turns fall back to the account's natively hydrated connectors (unfiltered) and the serve host logs why. - Backend execution covers the account's HTTP/SSE servers; stdio servers
cannot run server-side (author a
{ command }MCP connection for those). - Exposure: whoever can talk to the agent can drive these connectors
(they are ordinary agent tools).
serverefuses to start when--allow-anonymousis combined with Cursor account MCP connections; use--bearer-tokenon shared hosts.
Every MCP connection is always available in three places:
- the Cursor agent (local or cloud), via SDK
mcpServers - the agent-serve host, for in-process tools via
ctx.host.mcp - channel / schedule handlers, via
args.host.mcp(deterministic — no agent loop)
export default defineTool({
description: "Search Linear issues.",
inputSchema: z.object({ query: z.string() }),
async execute({ query }, ctx) {
return ctx.host.mcp.callTool("linear", "list_issues", { query });
},
});// agent/channels/webhook.ts — call MCP directly from a webhook
POST("/sync", {
bodySchema: z.object({}),
handler: async (_req, { host }) => {
const result = await host.mcp.callTool("linear", "list_issues", {});
return Response.json(result);
},
});host.mcp.names() lists MCP connection names; listTools(name) and
callTool(name, tool, args) open the client lazily on first use.
Subagents (agent/subagents/<id>/)
A subagent is its own directory with the same agent.ts +
instructions.md shape. description is required — the parent model reads
it to decide when to delegate — and model is optional (inherit by
default). On the Cursor harness, subagents run as SDK custom subagents:
they inherit the parent's execution surface, so per-subagent tools/,
skills/, and mcp-connections/ are reported as warnings and ignored for now.
Channels (agent/channels/*.ts)
The built-in HTTP channel is always mounted (under /<slug> in the
default multi-agent layout; at the server root with mode: "single"):
POST /v1/session— start a session ({"message": "..."}; returnssessionId+continuationToken)POST /v1/session/:sessionId— follow-up ({"message", "continuationToken"}; rotates the token; works for any chat session including custom channels likedrive;409on stale tokens, busy sessions, or task/schedule sessions;403if the caller is not the session owner)GET /v1/session/:sessionId/stream?startIndex=N— replay + live NDJSON (same owner check)GET /v1/session/:sessionId/approvals— pending human-in-the-loop tool approvals for the sessionPOST /v1/session/:sessionId/approvals/:callId— approve or deny ({"decision":"approve"|"deny"})GET /v1/sessions— sessions owned by the calling principalPOST /v1/tools/:toolName— call a server tool deterministically ({"input": {...}, "sessionId"?}; see "Deterministic tool calls")GET /v1/health,GET /v1/info— liveness and the manifest snapshot Authoragent/channels/http.tsonly to override its defaults:
import {
bearerAuth,
httpChannel,
localDevStrict,
} from "@cursor/july/channels";
export default httpChannel({
auth: [localDevStrict(), bearerAuth(process.env.AGENT_TOKEN ?? "")],
onMessage: (message, { auth }) =>
`[caller ${auth?.principalId ?? "anonymous"}] ${message}`,
});Custom channels declare their own routes (mounted under
/v1/channels/<id>), observe stream events for the sessions they own, and
control their continuation-token format (e.g. a thread id):
import { defineChannel, POST } from "@cursor/july/channels";
import { z } from "zod";
export default defineChannel({
routes: [
POST("/message", {
description: "Enqueue a chat turn on this channel",
bodySchema: z.object({
message: z.string(),
thread: z.string().optional(),
}),
handler: async (_req, { send, body }) => {
const { message, thread } = body;
// `auth` defaults to the request principal resolved by the auth chain.
const session = await send(message, {
continuationToken: thread, // stable key: same thread, same session
});
return Response.json({ sessionId: session.id });
},
}),
],
events: {
"message.completed"(event, channel, ctx) {
// deliver the reply back to the surface that owns this channel
},
},
});GET requires a Zod querySchema and POST / PUT / PATCH require a
Zod bodySchema at compile time — plain JSON Schema objects will not
type-check. Use z.object({}) or z.unknown() when the surface is
intentionally open. Schemas are validated by the host before the handler
runs (handlers get typed args.body / args.query) and projected on
GET /v1/info so the playground Agent surface can Try the route, and
so the composer can offer matching slash commands (e.g. /drive).
Route handlers receive a Fetch Request and helpers: send, getSession,
receive (cross-channel hand-off), params, requestIp, auth, host
(shared host services — MCP / GitHub / Slack; same as tool ctx.host), and
waitUntil. Channel state declares initial per-session adapter state,
persisted across events; handlers receive it on channel.state.
Auth: every route runs an auth-policy chain (auth on the channel).
The default is [localDevStrict()] — direct loopback callers only, with
proxy-forwarding headers and non-loopback Host rejected — so nothing
is exposed publicly until you add real auth (bearerAuth(...), a custom
policy, or the explicit allowAll()).
Slack (@cursor/july/channels/slack): a platform channel pack
that defaults to Socket Mode. Author agent/channels/slack.ts with
slackChannel():
import { slackChannel } from "@cursor/july/channels/slack";
// Single agent — SLACK_BOT_TOKEN + SLACK_APP_TOKEN
export default slackChannel();
// Multi-agent serve — one Slack app (and token pair) per agent
export default slackChannel({ envPrefix: "WEATHER_AGENT" });
// → WEATHER_AGENT_SLACK_BOT_TOKEN + WEATHER_AGENT_SLACK_APP_TOKEN
// Cursor account connection — no dedicated Slack app. `@Cursor Weatherbot …`
// routes here; replies post as "Weatherbot" through the Cursor Slack app.
export default slackChannel({ cursorAccount: true, agentName: "Weatherbot" });Transport uses @slack/socket-mode + @slack/web-api. Socket Mode starts
on channel mount when both tokens are present; otherwise the channel stays
idle (channel idle … missing credentials) so multi-agent serve can
mount agents that do not have Slack tokens configured yet.
The pack dispatches via waitUntil, streams assistant text (chat.startStream /
appendStream / stopStream) with postMessage fallback, shows rotating status
- tool thinking steps, and sets thread titles / suggested prompts. Handlers may
return a prepared
message/workspaceFiles/cloudto host-prepare PR reviews or attach cloud repos from an@mention.
Engagement: by default the agent is summoned, never proactive — it
dispatches only on app_mention and DMs. Channel watch is an explicit opt-in:
export default slackChannel({
envPrefix: "TRIAGE",
engagement: {
// mentions / directMessages default to true
channelPosts: {
allow: ["#triage-alerts"], // explicit allowlist only; no wildcard
posts: "top-level", // default: never dispatch on thread replies
debounceMs: 15_000, // optional: let rapid edits settle
},
},
onChannelPost: async (ctx, message) => {
// Same contract as onAppMention: return null to skip.
return message.markdown.length > 20 ? {} : null;
},
});Watched posts dispatch with the same thread-scoped principal as mentions
(so a later @mention continues the session) plus an
engagement: "channel_post" auth attribute. Posts that mention the bot are
left to the app_mention path; bot-authored posts never dispatch; deleting
a post inside the debounce window cancels its dispatch. The Slack app must
subscribe to message.channels / message.groups
(agent-serve slack init --channel-posts, or add the events to an existing
app) and be a member of each watched channel. Watched channels surface in
info via meta.slackChannelPosts.
HITL compose: park/resume is independent (needsApproval on tools). Opt in
to Slack delivery with toolApprovals: true (or spread
buildToolApprovalEvents into events and set interactivity: true on
slackChannel so Approve/Deny buttons resolve). That posts Block Kit
Approve/Deny cards and routes Socket Mode interactive clicks to
resolveApproval. Enable interactivity: true on
buildSlackManifest when you use that surface. Threads bind with
continuationToken = channelId:threadTs. Dev and prod are
separate Slack apps. Requires SLACK_BOT_TOKEN + SLACK_APP_TOKEN
(App-Level Token with connections:write).
Most demos under examples/ use the Cursor Slack connection
(cursorAccount: true) with a unique single-token agentName —
@Cursor Benny …, @Cursor Bugbot …, @Cursor ApprovalBuddy …, etc.
Sign the host in, then mention the agent; no per-agent Slack app required
for chat. Agents that need channel watch or tool approvals keep a Socket
Mode app in a second channel file (e.g. slack-app.ts) with their own env
prefix (<PREFIX>_SLACK_BOT_TOKEN + <PREFIX>_SLACK_APP_TOKEN, derived
from the directory name by slack init).
| Agent | @Cursor name | Optional Socket Mode app |
| --- | --- | --- |
| weather-agent | Weather | slack-app.ts (WEATHER_AGENT_SLACK_*, tool approvals) |
| slack-agent | SlackAgent | — |
| bugbot | Bugbot | — |
| fsd | FSD | — |
| benny | Benny | slack-app.ts (BENNY_SLACK_*, channel watch) |
| approval-buddy | ApprovalBuddy | — |
PR-oriented demos (bugbot, fsd) extract a GitHub PR URL / owner/repo#N
from the mention and run the same host path as their HTTP channels.
security-reviewer is GitHub-task-only (no Slack channel); concierge is
agent-to-agent only (no Slack channel).
For Socket Mode apps (channel watch / approvals): generate manifests →
create apps → install + mint app token → env →
agent-serve slack doctor --prefix … → serve.
CLI (agent-serve slack …; guided setup in skills/setup-slack/SKILL.md):
agent-serve slack setup
agent-serve slack init --dir ./my-agent --name "My Agent"
agent-serve slack doctor --prefix MY_AGENTinit / manifest write .agent-serve/slack/manifest.{dev,prod}.json and
env.example.
GitHub (@cursor/july/channels/github): a webhook channel pack
(Eve-compatible dispatch). Author agent/channels/github.ts with
githubChannel() and declare inbound hooks:
import { defaultGitHubAuth, githubChannel } from "@cursor/july/channels/github";
export default githubChannel({
botName: "my-agent", // or GITHUB_APP_SLUG
cursorAccount: {
repos: ["owner/repo"],
// permissions?: "read" | "pr-write" | "contents-write" (default pr-write)
},
onPullRequest: (ctx, pr) =>
pr.action === "opened" ? { auth: defaultGitHubAuth(ctx) } : null,
onCheckSuite: (ctx, suite) =>
suite.conclusion === "failure" ? { task: () => triage(ctx) } : null,
});Mounts at POST /<slug>/v1/channels/github. When a webhook secret is set the
channel verifies X-Hub-Signature-256 before parsing (the HMAC becomes the
request auth); without one it stays loopback-only (localDevStrict()) — except
under serve --dev, which admits unsigned loopback deliveries so
gh webhook forward and fixtures work with zero config. Hooks return
{ auth } to start a model turn as the actor, { task } for host-side work
(202 ACK, runs past GitHub's ~10s timeout), or null to skip.
Testing GitHub agents locally. cursorAccount reuses agent-serve login
for both Cursor SCM events and a refreshing repo-scoped GitHub credential used
by ctx.github, ctx.host.github, and child gh commands. Default
permissions: "pr-write" covers comments without contents:write; opt up to
"contents-write" only when the agent must push. Without cursorAccount, API
auth prefers GitHub App installation tokens when GITHUB_APP_ID /
GITHUB_APP_PRIVATE_KEY (and an installation id) are set, then falls back to
GITHUB_TOKEN / GH_TOKEN or gh auth login. The channel publishes the
webhook events it dispatches on (derived from the declared hooks, or pinned
via webhookEvents), so agent-serve github … can forward live
deliveries with zero hand-listing — it wraps gh webhook
forward:
# One-time: gh + the cli/gh-webhook extension
agent-serve github doctor --install
# Terminal A: serve the agent (dev accepts unsigned loopback deliveries)
agent-serve serve --dir ./my-agent --dev
# Terminal B: forward this repo's deliveries to the discovered github channel
# (URL + events auto-derived; repo inferred from the git remote). No secret
# needed against a --dev server; set GITHUB_WEBHOOK_SECRET to verify signatures.
agent-serve github forward --dir ./my-agent
# Inspect what would be forwarded (URL + events per agent)
agent-serve github events --dir ./agents --json
# Forward to EVERY discovered github channel at once
agent-serve github forward --dir ./agentsWhen several channels match (e.g. a folder of agent projects with more than
one github channel), one forwarder fans out to all of them: gh webhook
forward runs against a local proxy that re-posts each raw (still-signed)
delivery to the channels whose event set matches. This is required — GitHub
allows only one forwarder per repo, and gh webhook forward targets a single
URL, so N processes would collide with Hook already exists.
gh webhook forward needs admin on the repo (or org owner for --org) —
it registers a real webhook — and authenticates its relay with the GitHub CLI's
own login. If GITHUB_TOKEN / GH_TOKEN is set in your env, deliveries fail
with HTTP 401 (the relay rejects env tokens); blank it for the command
(GITHUB_TOKEN= GH_TOKEN= agent-serve github forward …) or unset it.
agent-serve github doctor / forward warn when they detect this.
Pass --repo owner/repo / --org ORG to override the inferred target,
--events a,b,c to narrow the set, --url for a custom endpoint (e.g. a
tunnel), and --slug / --channel to forward to just one of several agents.
Against a --dev server no secret is needed; set GITHUB_WEBHOOK_SECRET (or
--secret) to exercise signature verification (required for a non-dev target).
Only one forwarder per repo/org at a time (a GitHub limitation). Fixture replay
still works too — POST a saved payload with an x-github-event header (no
signature needed in --dev).
No admin? Hillclimbing? Use github replay. gh webhook forward needs repo
admin and a live event. agent-serve github replay <pr_url> instead reads
the PR (pull access is enough — no admin, no relay, and GITHUB_TOKEN is fine)
and synthesizes GitHub-shaped payloads it POSTs straight at the channel:
# Replay a pull_request delivery for a PR to the discovered channel
agent-serve github replay https://github.com/owner/repo/pull/123 --dir ./my-agent
# Replay everything the channel listens for (its declared events), CI failing
agent-serve github replay owner/repo#123 --dir ./my-agent --events '*' --conclusion failure
# Inspect payloads without POSTing (and snapshot them as reusable fixtures)
agent-serve github replay owner/repo#123 --dir ./my-agent --events '*' --dry-run --out fixtures/github--events defaults to pull_request (* = the channel's declared events);
--action / --conclusion / --comment / --context tune each synthesized
event; --secret (or GITHUB_WEBHOOK_SECRET) signs them so a secret-configured
channel verifies. Because replay sends a clean, signed loopback request, it
passes both the localDevStrict() and allowAll()+signature auth modes — and
it's fully deterministic, which is what hillclimbing wants.
Production alternative: pull from Cursor (--cursor-events). If the
Cursor GitHub App is on the repo, serve --cursor-events --repo owner/repo
long-polls /v0/scm-events with the host's Cursor account (no public URL /
repo admin). Offset + consumer id live under <state-root>/cursor-events/.
The stream is read as your Cursor user, so the flag requires a signed-in
host (agent-serve login, CURSOR_API_KEY, or serve({ apiKey })) — serve
fails fast rather than starting with a relay that can never receive events.
Hooks (agent/hooks/*.ts)
Observe-only subscribers that run after each event is recorded — audit
logs, metrics, mirroring transcripts into your own store. Keys are event
types (or *); handler errors are logged, never fatal.
Schedules (agent/schedules/*)
Markdown form (fire-and-forget task session):
---
cron: "0 9 * * 1-5"
---
Pull open incidents and post a summary to the metrics endpoint.Handler form (full control, hand off to a channel):
import { defineSchedule } from "@cursor/july/schedules";
import webhook from "../channels/webhook.js";
export default defineSchedule({
cron: "*/30 * * * *",
async run({ receive, waitUntil, appAuth, mcp }) {
// optional: await mcp.callTool("units", "celsius_to_fahrenheit", { value: 0 });
waitUntil(
receive(webhook, {
message: "Check for new critical alerts. Report only when there are any.",
auth: appAuth,
})
);
},
});Cron expressions are standard 5-field, evaluated in UTC with minute
granularity. In production mode (agent-serve serve) schedules fire on
cadence; in dev mode (--dev) they never fire automatically — dispatch one
by hand, exactly once, through the same path production uses:
curl -X POST http://127.0.0.1:3000/<slug>/v1/dev/schedules/heartbeat
# {"scheduleId":"heartbeat","sessionIds":["ses_..."]}Sessions, events, and streaming
Two handles do two jobs: the continuation token resumes a conversation (owned by the channel; one active continuation per session, stale tokens rejected), and the session id streams and inspects it (owned by the runtime).
Reminders
Per-session durable wakes (distinct from deploy-time agent/schedules/):
await handle.createReminder({
purpose: "ci_recheck",
channelId: "drive",
continuationToken: "pr-owner-repo-1",
delay: "2h",
prompt: "Re-check CI. Only act if still failing.",
until: "Cancel once CI is green or the PR is merged.",
});Host/policy packs may pass run (return stop / skip / delivered)
instead of prompts. In --dev, use POST /v1/dev/reminders/:id (or
handle.dispatchReminder) to fire; auto-timers follow
ServeOptions.reminders (default !dev). Run handlers are in-memory —
after restart those reminders are disarmed (handler_lost_on_restart);
re-arm from enroll/policy.
The NDJSON stream vocabulary — one JSON object per line, each carrying
{ type, index, sessionId, turnId?, at, data }:
| Event | Meaning |
| --------------------- | -------------------------------------------------------------- |
| session.started | A durable session was created. |
| agent.bound | Cursor agent id is known (sdkAgentId; cloud: bc-… + URL). |
| ab.assigned | Sticky A/B enrollment (experiment, variant or null skip). |
| message.received | An inbound user message was accepted. |
| turn.started | A turn began. |
| step.started / step.completed | Model step boundaries (with duration). |
| reasoning.appended / reasoning.completed | Reasoning deltas and the block end. |
| message.appended | Assistant text delta (with cumulative text so far). |
| message.completed | A finalized assistant text block (finishReason: stop or tool_call). |
| actions.requested | The model requested a tool call (streams before execution). |
| action.approval_requested | A needsApproval tool is parked awaiting a human. |
| action.approval_resolved | Human approved or denied the parked tool call. |
| action.result | A tool call returned (output, isError). |
| subagent.called / subagent.completed | Delegation to a subagent. |
| turn.completed | The turn finished (result, usage). |
| turn.failed | The turn failed (message). |
| session.waiting | The session parked, ready for the next message. |
| session.completed / session.failed | Terminal states for task-mode (schedule) sessions. |
The stream is durable: reconnect with ?startIndex=<n> to replay from any
point, or from 0 to rewind the whole session.
Serving programmatically
import { serve } from "@cursor/july";
const handle = await serve("./my-agent", {
port: 3000,
apiKey: process.env.CURSOR_API_KEY, // optional — see credential order below
});
console.log(`listening on ${handle.url}`);
// handle.dispatchSchedule("heartbeat"), handle.project, await handle.close()The Cursor credential resolves in one order everywhere (SDK turns, cloud
runtime, Cursor account MCP connections): explicit apiKey option / --api-key
→ CURSOR_API_KEY → the key stored by agent-serve login. whoami shows
which one is active; logout removes the stored key (revoke it in the
Cursor dashboard to kill it outright).
Local-dev note: login/account RPCs honor CURSOR_API_BASE_URL while the
Cursor SDK harness honors CURSOR_BACKEND_URL. When pointing at a
non-production backend, set both to the same URL — a key minted on one
backend is rejected by the other.
serve refuses to start when discovery produced error diagnostics; run
agent-serve validate (or read project.diagnostics) to see why.
State lives under <project>/.agent-serve/ (override with stateRoot /
--state-root): sessions/<id>/{session.json,events.ndjson,workspace/}
plus the SDK conversation store under runner/. Delete a session directory
to forget that conversation.
Session workspaces are real Cursor project directories, so the harness also
loads ambient project config from ancestor directories (nested
AGENTS.md / .cursor rules and skills). That's usually what you want when
the agent project is its own repository — but when it sits inside a large
monorepo, either set local: { cwd } on defineAgent to a directory outside
the monorepo (each session uses <cwd>/<sessionId>), or point --state-root
somewhere outside (e.g. under /tmp or XDG state) so sessions don't inherit
the monorepo's rules into context.
Not supported (yet)
Deliberately out of scope for now, and reported as warnings where the
corresponding folder exists: per-subagent tools/skills/mcp-connections, nested
subagents, WebSocket channel routes, platform channel packs for Discord
and Teams (Slack is supported via @cursor/july/channels/slack;
other platforms still use the authored defineChannel webhook form),
OAuth-brokered MCP connections beyond the Cursor account
({ cursorAccount: true } covers connectors the account already
authorized in Cursor), custom sandbox backends (sandbox.ts),
instrumentation modules, dynamic per-caller capabilities, structured
output schemas, and file uploads. Human-in-the-loop tool approvals
(needsApproval on execution: "server" tools) are supported; exact
resume of a parked tool call after host restart is not. Filesystem
evals (defineEval under evals/), live A/B metrics (defineAB under
agent/ab), and the run / trajectory commands are supported;
LLM-as-judge scoring and external reporters are not yet. Context
compaction is handled by the Cursor harness rather than
configured here.
