ACP transport, Cursor, and Grok
A shared JSON-RPC runtime gives Cursor and Grok one transport skeleton, while their startup, mode/model, extension, interruption, and rollback semantics deliberately diverge.
What this chapter resolves
- Trace an ACP child from stdio JSON-RPC through a normalized hot runtime event.
- Separate negotiated ACP schema affordances from the product adapter’s declared SPI capability.
- Compare Cursor’s extensions and local transcript rollback with Grok’s XAI extensions and explicitly unsupported native rollback.
- Explain why steering and interruption need provider-specific race handling.
Cursor and Grok are not two copies of a generic chat API. Both launch an Agent Client Protocol (ACP) child over stdio and reuse T3’s ACP session runtime; after that shared transport, their adapter code makes different promises. Those differences matter more than the shared method names when a meta-harness decides whether a turn can be resumed, steered, cancelled, or rolled back.
This chapter uses ACP capability in its protocol sense only: the upstream
initialization response can describe optional operations such as session load, fork,
resume, or model switching. It does not mean T3 exposes a dynamic capability-negotiation
surface to its own callers. The inspected T3 ProviderAdapter SPI declares only
sessionModelSwitch; it is reported as in-session by these adapters. Do not turn the
comparison below into a claim that every row is a negotiated runtime feature.
The reusable ACP machine
packages/effect-acp is a typed ACP JSON-RPC client/server bridge. It owns a child
process stdio transport, routes agent requests back to registered client handlers, and
exposes typed calls for initialization, authentication, session lifecycle, prompt,
cancel, configuration, and extension messages. AcpSessionRuntime puts the operational
policy on top: it spawns the child inside a scope, serializes prompts, holds the active
prompt fiber, parses session/update notifications, and gives adapters a queue of
normalized ACP observations.
{
readonly raw: AcpClientRaw;
readonly agent: {
/**
* Initializes the ACP session and negotiates capabilities.
* @see https://agentclientprotocol.com/protocol/schema#initialize
*/
readonly initialize: (
payload: AcpSchema.InitializeRequest,
) => Effect.Effect<AcpSchema.InitializeResponse, AcpError.AcpError>;
/**
* Performs ACP authentication when the agent requires it.
* @see https://agentclientprotocol.com/protocol/schema#authenticate
*/
readonly authenticate: (
payload: AcpSchema.AuthenticateRequest,
) => Effect.Effect<AcpSchema.AuthenticateResponse, AcpError.AcpError>;
/**
* Logs out the current ACP identity.
* @see https://agentclientprotocol.com/protocol/schema#logout
*/
readonly logout: (
payload: AcpSchema.LogoutRequest,
) => Effect.Effect<AcpSchema.LogoutResponse, AcpError.AcpError>;
/**
* Starts a new ACP session.
* @see https://agentclientprotocol.com/protocol/schema#session/new
*/
readonly createSession: (
payload: AcpSchema.NewSessionRequest,
) => Effect.Effect<AcpSchema.NewSessionResponse, AcpError.AcpError>;
/**
* Loads a previously saved ACP session.
* @see https://agentclientprotocol.com/protocol/schema#session/load
*/
readonly loadSession: (
payload: AcpSchema.LoadSessionRequest,
) => Effect.Effect<AcpSchema.LoadSessionResponse, AcpError.AcpError>;
/**
* Lists available ACP sessions.
* @see https://agentclientprotocol.com/protocol/schema#session/list
*/
readonly listSessions: (
payload: AcpSchema.ListSessionsRequest,
) => Effect.Effect<AcpSchema.ListSessionsResponse, AcpError.AcpError>;
/**
* Forks an ACP session.
* @see https://agentclientprotocol.com/protocol/schema#session/fork
*/
readonly forkSession: (
payload: AcpSchema.ForkSessionRequest,
) => Effect.Effect<AcpSchema.ForkSessionResponse, AcpError.AcpError>;
/**
* Resumes an ACP session.
* @see https://agentclientprotocol.com/protocol/schema#session/resume
*/
readonly resumeSession: (
payload: AcpSchema.ResumeSessionRequest,
) => Effect.Effect<AcpSchema.ResumeSessionResponse, AcpError.AcpError>;
/**
* Closes an ACP session.
* @see https://agentclientprotocol.com/protocol/schema#session/close
*/
readonly closeSession: (
payload: AcpSchema.CloseSessionRequest,
) => Effect.Effect<AcpSchema.CloseSessionResponse, AcpError.AcpError>;
/**
* Selects the active model for a session.
* @see https://agentclientprotocol.com/protocol/schema#session/set_model
*/
readonly setSessionModel: (
payload: AcpSchema.SetSessionModelRequest,
) => Effect.Effect<AcpSchema.SetSessionModelResponse, AcpError.AcpError>;
/**
* Updates a session configuration option.
* @see https://agentclientprotocol.com/protocol/schema#session/set_config_option
*/
readonly setSessionConfigOption: (
payload: AcpSchema.SetSessionConfigOptionRequest,
) => Effect.Effect<AcpSchema.SetSessionConfigOptionResponse, AcpError.AcpError>;
/**
* Sends a prompt turn to the agent.
* @see https://agentclientprotocol.com/protocol/schema#session/prompt
*/
readonly prompt: (
payload: AcpSchema.PromptRequest,
) => Effect.Effect<AcpSchema.PromptResponse, AcpError.AcpError>;
/**
* Sends a real ACP `session/cancel` notification.
* @see https://agentclientprotocol.com/protocol/schema#session/cancel
*/
readonly cancel: (
payload: AcpSchema.CancelNotification,
) => Effect.Effect<void, AcpError.AcpError>;
};Text equivalent
An ACP child process communicates via stdio JSON-RPC with the effect-acp client. AcpSessionRuntime initializes it, authenticates it, and either loads a cursor-named session or makes a new one. Parsed updates, permission callbacks, and extension callbacks enter a local adapter queue. The Cursor or Grok adapter turns selected payloads into product runtime events. A separate runtime-ingestion path can turn selected observations into durable orchestration commands and projections. The child process, Deferred callback waits, active prompt fiber, and event queue are not SQLite state.
The runtime gives a resumed session special treatment. It asks ACP for session/load
with the stored native session id and suppresses replay-shaped updates during an
initial bounded gate. It can complete on an RPC response or an idle replay gap, with a
default 90-second timeout. That prevents a historical replay from being emitted as a
new live turn, but it does not make the child’s upstream history a durable T3
projection.
Cursor: config-driven mode, typed extensions
Cursor spawns cursor-agent … acp, optionally passing a configured endpoint. Its ACP
runtime advertises Cursor’s parameterized model-picker capabilities; start requires a
non-empty working directory and may pass a persisted native sessionId as a resume
input. It can add T3’s MCP server to that ACP session when one exists for the thread.
After ACP startup, the adapter first applies the requested model and then configuration
options. It derives a suitable Cursor mode from T3’s runtime/interaction intent:
plan/architect aliases for plan work; approval/implement aliases for ordinary work.
Those are selection heuristics over Cursor’s returned modes, not universal names
guaranteed by ACP. A missing matching mode falls back to another available non-plan
mode or the current mode.
function resolveRequestedModeId(input: {
readonly interactionMode: ProviderInteractionMode | undefined;
readonly runtimeMode: RuntimeMode;
readonly modeState: AcpSessionModeState | undefined;
}): string | undefined {
const modeState = input.modeState;
if (!modeState) {
return undefined;
}
if (input.interactionMode === "plan") {
return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id;
}
if (input.runtimeMode === "approval-required") {
return (
findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
modeState.currentModeId
);
}
return (
findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ??
findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ??
modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ??
modeState.currentModeId
);
}
function applyRequestedSessionConfiguration<E>(input: {
readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
readonly runtimeMode: RuntimeMode;
readonly interactionMode: ProviderInteractionMode | undefined;
readonly modelSelection:
| {
readonly model: string;
readonly options?: ReadonlyArray<ProviderOptionSelection> | null | undefined;
}
| undefined;
readonly mapError: (context: {
readonly cause: import("effect-acp/errors").AcpError;
readonly method: "session/set_config_option" | "session/set_mode";
}) => E;
}): Effect.Effect<void, E> {
return Effect.gen(function* () {
if (input.modelSelection) {
yield* applyCursorAcpModelSelection({
runtime: input.runtime,
model: input.modelSelection.model,
selections: input.modelSelection.options,
mapError: ({ cause }) =>
input.mapError({
cause,
method: "session/set_config_option",
}),
});
}
const requestedModeId = resolveRequestedModeId({
interactionMode: input.interactionMode,
runtimeMode: input.runtimeMode,
modeState: yield* input.runtime.getModeState,
});
if (!requestedModeId) {
return;
}
yield* input.runtime.setMode(requestedModeId).pipe(
Effect.mapError((cause) =>
input.mapError({
cause,
method: "session/set_mode",
}),
),
);
});The native extension surface adds product-specific meaning:
cursor/ask_questionparks a Deferred answer, emits canonicaluser-input.requested, and sends a resolved event when T3 responds.cursor/create_planbecomesturn.proposed.completedwith plan markdown.cursor/update_todosis normalized into plan updates.- standard ACP
session/request_permissioneither auto-selects an allowed option in full-access mode or parks a Deferred approval and emits canonical request events.
The Deferred is deliberately hot. A product request becomes durable only after the later ingestion command succeeds; a restart cannot re-enter an in-process ACP callback by reading the projection alone.
Grok: a different ACP dialect on the same rails
Grok starts grok agent stdio, selects API-key versus cached-token authentication
from the environment, and adds the t3code OAuth referrer. It uses the same base
runtime lifecycle—initialize, authenticate, load-or-new session, queued updates—but
wraps it with XAI prompt-completion handling and its own model resolver. A requested
Grok model is applied with unstable ACP session/set_model only when it differs from
the session setup’s current model.
export function buildGrokAcpSpawnInput(
grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
cwd: string,
environment?: NodeJS.ProcessEnv,
): AcpSessionRuntime.AcpSpawnInput {
return {
command: grokSettings?.binaryPath || "grok",
args: ["agent", "stdio"],
cwd,
env: {
...environment,
[GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER,
},
};
}
function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string {
return environment?.[GROK_API_KEY_ENV]?.trim()
? GROK_AUTH_METHOD_API_KEY
: GROK_AUTH_METHOD_CACHED_TOKEN;
}
export const makeGrokAcpRuntime = (
input: GrokAcpRuntimeInput,
): Effect.Effect<
AcpSessionRuntime.AcpSessionRuntime["Service"],
EffectAcpErrors.AcpError,
Crypto.Crypto | Scope.Scope
> =>
Effect.gen(function* () {
const acpContext = yield* Layer.build(
AcpSessionRuntime.layer({
...input,
spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment),
authMethodId: resolveGrokAuthMethodId(input.environment),
}).pipe(
Layer.provide(
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
),
),
);
const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
Effect.provide(acpContext),
);
return yield* makeXAiPromptCompletionRuntime(runtime);
});
export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
const trimmed = model?.trim();
const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build";
return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";
}
export function currentGrokModelIdFromSessionSetup(
sessionSetupResult:
| EffectAcpSchema.LoadSessionResponse
| EffectAcpSchema.NewSessionResponse
| EffectAcpSchema.ResumeSessionResponse,
): string | undefined {
return sessionSetupResult.models?.currentModelId?.trim() || undefined;
}
export function applyGrokAcpModelSelection<E>(input: {
readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
readonly currentModelId: string | undefined;
readonly requestedModelId: string | undefined;
readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
}): Effect.Effect<string | undefined, E> {
const shouldSwitchModel =
input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
if (!shouldSwitchModel) {
return Effect.succeed(input.currentModelId);
}
return input.runtime
.setSessionModel(input.requestedModelId)
.pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));
}Grok’s user-input extension is x.ai/ask_user_question (and an underscore-prefixed
variant). It has a distinct cancellation response shape. Its standard ACP permission
path is similar to Cursor’s but chooses a compatible permission option rather than
assuming identical labels. Both adapters map tool, item, content, and plan updates
into product runtime events, but neither claims that every native field becomes
durable product data.
Where shared names fork
One shared ACP rail, four distinct product decisions
Select a concern to compare concrete adapter behavior and the boundary it does not cross.
Selected Model & mode
Model & mode
- Cursor
- Sets a base model, then resolves a mode through returned configuration/mode aliases.
- Grok
- Uses the setup result’s current model and calls session/set_model only if the requested model differs.
- Boundary
- Both call into ACP, but their selection policies are adapter code—not a generic negotiated product feature.
All comparisons
- Model & mode
Cursor: Sets a base model, then resolves a mode through returned configuration/mode aliases.
Grok: Uses the setup result’s current model and calls session/set_model only if the requested model differs.
Boundary: Both call into ACP, but their selection policies are adapter code—not a generic negotiated product feature.
- Question
Cursor: cursor/ask_question parks a Deferred and maps answers to canonical user-input events.
Grok: x.ai/ask_user_question (including an underscore variant) has its own response and cancellation mapping.
Boundary: Deferred callback waits are in-process. Durable requests exist only after later runtime ingestion.
- Interrupt race
Cursor: Cancels ACP and resolves pending waits; prompt-in-flight counting prevents a superseded prompt from settling too early.
Grok: Marks target id before a thread lock and suppresses late notifications for interrupted turns.
Boundary: A cancel notification crosses a process boundary; neither path is a durable stop proof.
- Rollback
Cursor: Truncates the adapter’s local read snapshot; no Cursor native revert call appears in this method.
Grok: Returns an explicit unsupported provider request error.
Boundary: Neither row is an orchestration-event rollback or a transaction with provider history.
| Concern | Cursor adapter | Grok adapter |
|---|---|---|
| launch | cursor-agent … acp; optional endpoint | grok agent stdio; XAI auth/referrer setup |
| resume | passes a versioned Cursor session id to the shared ACP runtime | passes a versioned Grok session id to the same load-or-new path |
| mode/model | model plus config-option mode heuristic from discovered mode state | compares setup model then calls ACP session/set_model when needed |
| plan/input extensions | Cursor plan/todo/question extensions receive bespoke mappings | XAI ask-user extension receives bespoke mapping; ordinary ACP updates can still carry plans |
| running second prompt | reuses active turn id; only the last prompt completion settles it | same intent, with turn-target-aware settlement and interrupted-turn suppression |
| interrupt | cancels ACP and resolves pending approval/input waits | marks a target as interrupted before the lock, filters late notifications, then cancels and settles slots |
| rollback | emulates local rollback by truncating its in-memory turn snapshot | explicitly unsupported: returns a provider request error; no provider-side rollback is invoked |
The rollback row is intentionally asymmetric. Cursor’s implementation removes entries
from its local ctx.turns array; source inspection does not find a Cursor ACP native
revert call in that method. That changes the adapter’s read snapshot, not necessarily
the provider’s remote history. Grok refuses the operation explicitly. Orchestration
revert still has its own durable events and projection cleanup (Chapter 13); neither
adapter row should be read as a transaction across that durable model and native
provider state.
Cursor also serializes per-thread start/stop work and tracks prompts in flight, so a second prompt becomes a steer instead of a second product turn. Its completion logic lets only the final outstanding prompt settle the turn. The shared semantic—one product turn while upstream work is running—does not make the races identical.
Evidence boundaries and useful tests
The pinned tests cover model/mode setup, mid-turn steering, cancellation, callback
responses, and a regression where notification consumers accidentally died when the
startSession caller fiber ended. They do not prove that an arbitrary real Cursor or
Grok server will expose the same optional ACP capabilities, extension payloads, or
remote-history semantics. Those are server/version-dependent integrations.