NGL & interfaces
NGL (Native Glider Language) is the internal/ngl package. It contains
a small extension point for each vendor. These extension points let claude, cursor-agent and agy
operate with the shared code of Glider. The shared code never makes a decision from the name of a
vendor. There are three of them: two Go interfaces, and one family of functions with a shared name
pattern. Each one answers a different question, and the data moves in a different direction. This
page shows the difference between them. To add a fourth CLI, you must know that difference.
Why there are three extension points
Each one answers a different question. The data moves in a different direction, and at a different point in the pipeline:
| Extension point | The question | The data source | The direction |
|---|---|---|---|
OriginAdapter |
Is this HTTP request the live traffic of a vendor CLI to its own backend? If Glider must reply as that backend, what is the format? | A live HTTP request that Glider intercepted with MITM or with the gateway | Into Glider, from the network |
DelegateRenderer |
What text did a delegate run write? Remove all data except the final answer. | The recorded stdout of a completed subprocess | Out of Glider, from the subprocess to the chat reply |
The ParseXTurn functions( ParseClaudeTurn,
ParseCursorAgentTurn, ParseAgyTurn) |
What is the structure of one line of the stream-json output of a vendor? Is it text, a tool call, a tool result or reasoning? | One event line of the stream output of a vendor | Out of the subprocess, into a Turn and its Part items |
There is a fourth interface one layer below, in internal/vendors.
VendorAdapter controls what occurs when Glider runs a CLI. It finds the refusals, gets
the session ID, gives a limited permission for the next run with ExtraResumeArgs or
GrantResumePermission, reads the edits, and writes the resume prompt. It does not read
a message format. Refer to the section below for the full list of
methods, and to Delegation for the relay procedure. Do not confuse it
with the three extension points above. It is a different boundary, because it controls the execution of a
process and not a message format.
Turn and Part — the shared vocabulary
Each ParseXTurn function makes the same structure. turn.go defines it one
time:
type Turn struct {
Vendor string // "claude" | "cursor-agent" | "agy" | a future vendor
Raw json.RawMessage // the native event of the adapter, with no changes
Parts []Part
}
Glider always keeps Raw. The Parts data is additional, and it does not
replace Raw. Therefore Glider can always send a Turn back to the same
vendor with no changes. Parts gives the type of each item:
PartUserText, PartToolCall, PartToolResult,
PartReasoning and PartOther. This layer does not change a name. The
Name and Args of a ToolCall keep the words of the vendor. The
alias table of a vendor pack changes the names for a caller that needs the same names for each
vendor.
OriginAdapter — how Glider identifies the traffic of a CLI
Glider got this interface on 2026-07-27, after a live defect. The delegate handler in
internal/mitm used r.URL.Path != "/v1/messages" as its entry test. That
test is correct only when Claude Code is the front CLI. If you typed a delegate flag in the session
of cursor-agent or agy, nothing occurred. The traffic of those CLIs does not use the Anthropic
Messages format. Therefore the test refused the request before the flag parser operated.
type OriginAdapter interface {
Vendor() string
Matches(r *http.Request) bool
ExtractUserInstruction(body []byte) (text, model string, stream, ok bool, err error)
// header is written immediately, before replyText resolves — keeps a
// slow delegate call (headless run of another vendor's CLI) from
// idling the client out, and names what was delegated to whom.
WriteReply(w http.ResponseWriter, model string, stream bool, header string, replyText <-chan string) error
}
internal/mitm/delegate_handler.go calls ngl.ResolveOriginAdapter(r). It
never compares a host or a path with the name of a vendor. The Matches function of each
adapter does that. In ExtractUserInstruction, ok=false is not an error. It
means that the structure of the body is correct, but this adapter has no confirmed method
to separate the human text from the data that the vendor adds. The caller must then send the request
to the origin. The caller must not search the body for a substring. Glider used that method one time
for Claude, before NGL existed, and it caused the injection defect that the package doc comment
describes.
| Vendor | The message format | How Glider knows |
|---|---|---|
| claude | The Anthropic Messages API, POST /v1/messages | The specification is public |
| agy | REST in the Gemini format, POST .../v1internal:streamGenerateContent. The human text is inside <USER_REQUEST> tags. | Glider recorded the live traffic and compared the bytes on 2026-07-27 |
| cursor-agent | Connect-RPC on HTTP/2, POST /agent.v1.AgentService/Run | Glider recorded the live traffic. Each request field agrees with the public agent_v1.proto schema. |
The completion host of cursor-agent always uses HTTP/2. A tool that expects HTTP/1.1 cannot see this traffic. The MITM proxy of Glider had this problem until 2026-07-28. Refer to MITM for the correction.
DelegateRenderer — a clean reply, not the full transcript
Glider got this interface on 2026-07-28. vendors.RunResult.Text holds the full recorded
stdout of a delegate run. For claude and for cursor-agent, this is the complete
stream-json NDJSON transcript. It contains each internal event line, and not only the
answer. Glider asks for this format, because other code in the pipeline reads the refusals and the
session ID from it. This full transcript is correct for a diagnostic task, but it is not correct for
daily work.
type DelegateRenderer interface {
Vendor() string
Render(raw []byte) (clean string, ok bool)
}
The renderers for claude and cursor-agent read the NDJSON and look for the last
{"type":"result","result":"..."} line. That line holds the final answer of the vendor,
and Glider does not make the answer again. If there is more than one such line, Glider uses the
last one. LastUserInstruction makes the same choice for the incoming data. The
renderer for agy makes almost no change, because agy has no --output-format flag and its
stdout is already normal text. ok=false means that there is no result event, or that the
event is empty. The caller then uses the raw text. Glider does not try to find an answer that can be
absent.
vendors.ResolveDelegate uses this interface, and clean is the default. One
control on the Vendors page sets the format: Settings → Delegated task replies. There is no
flag for one message, because this is an operator setting. The raw mode always shows the full
transcript. The clean mode uses the raw text when a renderer gives ok=false, but it also
adds a note. Therefore you can always see that the format is different.
VendorAdapter — what occurs when Glider runs a CLI
This interface is in internal/vendors/adapter.go, and not in internal/ngl.
Each difference between the CLIs in the execution layer must go through this one interface.
RunWithOptions and ResolveDelegate find a VendorAdapter by the
vendor name. They do not know which CLI they use. To add a fourth vendor, or to change how a vendor
finds a refusal or gives a permission, you write or change one adapter here. You must not make a
decision from vendor.Name in the shared code.
type VendorAdapter interface {
// nil means "no denials found" — every adapter implements this, even a trivial no-op.
DetectDenials(stdout, stderr []byte) []Denial
// "" means none available (no such id in this vendor's format, or extraction failed).
ExtractSessionID(stdout []byte) string
// Scoped side effect OUTSIDE the resume argv itself; revert is always called after the
// resume attempt, success or failure. cwd is the resolved workspace directory the resume
// will run in (may be ""). Most vendors return a no-op revert — per-denial scoping happens
// through ExtraResumeArgs instead (see below).
GrantResumePermission(v Vendor, cwd string, denials []Denial) (revert func() error, err error)
// Extra CLI args to append to the vendor's "resume" CommandTemplate for this specific set
// of denials, or nil if the vendor has no such mechanism.
ExtraResumeArgs(denials []Denial) []string
// ok=false for a run that made no edit, or whose headless output carries no structured diff.
ExtractEditViews(stdout []byte) (views ngl.EditViews, ok bool)
// Lets a vendor reframe the resume prompt for its model's known behavior on a resumed call;
// identity (returns prompt unchanged) for vendors whose resume already reliably completes.
WrapResumePrompt(prompt string) string
}
A permission must change a condition before Glider runs the CLI again. If it does not, the new run
stops at the same permission. Each CLI has a different mechanism. Therefore the interface has
two methods for one function. ExtraResumeArgs gives the
command-line arguments for the refused tools. For example, claude makes
--allowedTools <comma-joined tool names> from the refusals. Glider knows the tool
name only after the refusal, and thus the name cannot be in a fixed template.
GrantResumePermission makes a change outside the command line. For example,
agy writes a limited rule in its settings.json, and Glider then puts the original bytes
back. Each vendor uses the method that its CLI supports. The shared code calls the two methods and
does not know which one operates. cursor-agent has no argument for one tool. It has only
-f or --yolo, which permit each tool, and Glider does not use them
automatically. Therefore its adapter gives nil from ExtraResumeArgs and
makes no change in GrantResumePermission. --resume [chatId] --trust is
sufficient.
ExtractEditViews shows the file differences through NGL. It reads the raw stdout of a
completed run and makes a standard ngl.EditViews structure. This is possible only when
the output of the vendor has sufficient structure. agy writes normal text only, because it has no
--output-format flag. Therefore its adapter always gives ok=false. This is
a true limit of the format, and Glider does not hide it. WrapResumePrompt changes the
resume prompt for a vendor whose model gets the permission but then gives a description of the
directory in place of the work. agy has this behavior today. This method decreases the problem, but
it does not prevent it. It is different from the two permission methods.
For a vendor name that has no adapter, Glider uses noopAdapter. Each method of that
adapter is safe and makes no change. Therefore a caller does not have to test for an adapter before
it calls one. Only the vendorAdapters map in adapter.go contains the full
list of the vendor names for execution.
How to add a fourth vendor
This is the function of the three extension points. To add a vendor, you write new files. You do not change the shared code. A complete addition has these parts:
configs/vendor_candidates.yaml— the probe arguments, the print flag, and thedefault,resumeandinteractivetemplates. This is data, and not Go code.internal/vendors/adapter.go— aVendorAdapterwith all six methods:DetectDenials,ExtractSessionID,GrantResumePermission,ExtraResumeArgs,ExtractEditViewsandWrapResumePrompt. These control the execution, and not the message format. Refer to VendorAdapter above.internal/ngl/adapter_<vendor>.go— aParseXTurnfunction. You need this only if the stdout of the vendor is more than normal text.internal/ngl/adapter_<vendor>_origin.go— anOriginAdapter. Write this only after you confirm the live message format. Refer to the table above. Do not register an adapter from an assumption.internal/ngl/adapter_<vendor>_render.go— aDelegateRenderer. Write this after you confirm the output format.
These five parts do not need a change to internal/mitm/delegate_handler.go, to vendors.ResolveDelegate, or to any other shared code.
The full history and the live-capture method are in planning/agent_cli_interop.md. For the delegate flag, refer to Delegation.