For any AI coding agent that needs to debug a Delphi Win32 or Win64 target end-to-end through the wire — including driving the target's UI to reproduce a bug. The guide covers the wire shape, recipes, and pause-loop semantics that work with any MCP-capable client. Pick the matching daemon by target bitness (rdbgd64 for Win64, rdbgd32 for Win32); the wire protocol is identical.
paused, breakpoint_hit, target_breakpoint, exception, debug_output, exit_process.list_windows, post_message, send_keys, send_key_sequence, click_at, capture_window — so an agent can drive a VCL UI without a human at the keyboard.spy_start / spy_stop turn on a low-level recorder that emits an {"event":"input", ...} for every mouse/keyboard event the user performs against the debuggee, tagged with the VCL component path (Form1.pnlBottom.btnOK). A human reproduces a bug once; the agent persists the JSONL and replays it on every iteration. See R5c — Bug-reproduction handoff.kernel32, ntdll, etc. resolve to module!Symbol+offset via Microsoft public PDBs auto-fetched on first use; set_breakpoint accepts the same module!symbol form (e.g. kernelbase!RaiseException).This is enough for an agent to reproduce a bug, set a BP at the suspected handler, drive the UI to fire it, and inspect the in-process Delphi state at the moment things go wrong — without ever rebuilding the target.
The daemon (rdbgd64.exe for Win64 targets, rdbgd32.exe for Win32) runs on the Windows host where your debuggee lives. The agent talks to it from anywhere over TCP — locally, across an SSH tunnel, or via the bundled MCP bridge.
# On the Windows host, start the daemon listening.
rdbgd64.exe --listen 127.0.0.1:5902
# From the agent's host: open a JSON-pipe REPL. Each line of stdout
# is one wire response or event; each line of stdin is one request.
# EOF on stdin sends `shutdown` and closes the daemon cleanly.
rdbgc.exe repl 127.0.0.1:5902
For scripted use, pipe a sequence of requests through with a bounded timeout — don't trust a daemon to be honest about deadlocks. Pattern: python3 -c "subprocess... timeout=N".
Every interaction the agent has is mediated by pause state:
RUNNING ── exception/BP/launch ──▶ PAUSED ── continue/step ──▶ RUNNING
│
other commands here
While paused, the agent can call any inspection method and the continue / step_* / run_to / shutdown / detach methods.
While running, the wire only returns event messages until the next pause. Sending an inspection method while running gets you either error: target_running or — depending on the method — silence until the next event.
An agent's algorithm is always event-driven. After continue, read the response stream until something interesting comes back, then act.
The fastest way to know whether something is happening: send {"method":"hello"} between every workflow step. It's a no-op that confirms the daemon is alive AND tells you the current pause state from the response shape.
The full reference is in docs/protocol.md; this is the agent-relevant slice.
| Group | Method | When | Notes |
|---|---|---|---|
| Lifecycle | hello | always | sanity check, no side effects |
| Lifecycle | launch | once / session | path is forward-slashed; daemon spawns target paused at loader BP |
| Lifecycle | attach | once / session | by pid or name (substring); kernel synthesizes the avalanche, lands paused |
| Lifecycle | list_processes | always | enumerate running PIDs + .map / .dpr discovery |
| Lifecycle | shutdown | always | terminates target + daemon |
| Lifecycle | detach (planned) | paused | leave target running, exit daemon |
| Flow | continue | paused | resume |
| Flow | step_over | paused | line-aware, skips CALL |
| Flow | step_into | paused | single-instruction including into callees |
| Flow | run_to | paused | one-shot temp BP at addr or src:line |
| Flow | set_rip | paused | move execution point (e.g. skip past AV) |
| BPs | set_breakpoint | always | by addr OR source+line; kind:"hw" for HW BPs |
| BPs | clear_breakpoint | always | by address OR bp_id |
| BPs | list_breakpoints | paused | enumerate active BPs |
| State | inspect_object | paused | full RTTI walk; max_depth controls eager vs lazy |
| State | read_memory | paused | up to 64 KiB, hex-encoded |
| State | read_string | paused | unicode/ansi/short kinds |
| State | read_dyn_array | paused | raw hex; client decodes |
| State | get_registers | paused | x64 GPR set + eflags; tid optional |
| State | get_self | paused | RCX heuristic for Self of method frame |
| State | inspect_locals | paused | params + stack-locals; needs target built with -V (TD32/TDS in EXE) |
| State | list_threads | paused | tids + last-known RIP per thread |
| UI | list_windows | paused | enumerate target HWNDs |
| UI | post_message | paused | low-level WM_* poke (named or numeric msg) |
| UI | send_keys | paused | UTF-16 → WM_KEYDOWN/CHAR/UP triplets |
| UI | send_key_sequence | paused | explicit {vk, down, char?} events for modifiers |
| UI | click_at | paused | WM_MOUSEMOVE/LBUTTONDOWN/UP at hwnd-relative coord |
| UI | capture_window | paused | BitBlt screen DC → PNG → base64 |
UI methods accept either params.hwnd (raw hex) or params.target (caption / class name lookup). They post into the target's window queue while the daemon is paused; messages drain on the next continue.
Operational patterns from least to most agent-driven. Each one is self-contained — mix and match for your scenario.
When the agent doesn't own the launch — the target was already running, or it's a long-lived service the user wants live inspection on:
// What's running that looks debuggable?
{"method":"list_processes","params":{"has_map_only":true}}
// response carries [{pid, name, exe_path, map_path?,
// source_dir?, dpr_path?}]
// Attach by name (first match)
{"method":"attach","params":{"name":"my-service.exe"}}
// or by exact PID
{"method":"attach","params":{"pid":1234}}
After attach the daemon synthesizes the standard event stream — create_process, a load_dll per loaded module, create_thread per existing thread, then a synthesized breakpoint (DbgUiRemoteBreakin) that lands paused. From there it behaves exactly like R1/R2.
The daemon checks <exe>.map next to the binary, and walks 2 parent directories looking for <name>.dpr (also under source/ and src/ subdirs at each level). Empty fields just mean nothing was found there — the target may still be debuggable; the agent loses source-level BPs but gets address-level access.
The minimal "is it broken in an obvious way" probe.
{"method":"hello"}
{"method":"launch","params":{"exe":"C:/builds/.../app.exe"}}
{"method":"continue"}
Response stream contains create_process, then loader-BP paused, then your continue ack, then whatever the target produces: debug_output events, eventually exit_process with an exit code. If you see an exception event, you've reproduced something without lifting a finger.
The bread-and-butter agent workflow: pick a likely handler from source, set a BP, run the target until it lands.
{"method":"launch","params":{"exe":"..."}}
{"method":"set_breakpoint",
"params":{"source":"MainForm.pas","line":248}}
{"method":"continue"}
Then read the stream. When breakpoint_hit arrives, the target is paused at the BP's RIP. From here:
{"method":"get_self"} // capture Self ptr
{"method":"inspect_object",
"params":{"address":"<self-from-above>","max_depth":2}}
{"method":"get_registers"}
get_self returns {"address":"0x...","class":"TForm1"} if RCX on entry to a method holds a Self pointer; falls back to not_object:true for non-method frames.
If the target was built with -V (Project > Options > Linking > "Place debug information in EXE"), inspect_locals beats poking RCX by hand: it returns named parameters and stack-locals for the procedure containing the paused RIP.
{"method":"inspect_locals"}
→ {"ok":true,"frame_idx":0,"function_symbol":"MainForm.TForm1.btnSaveClick",
"auto_walked_frames":0,"version_warning":null,
"locals":[
{"name":"Self", "location":"reg", "reg_id":0, ...},
{"name":"Sender","location":"reg", "reg_id":1, ...}
]}
auto_walked_frames is the number of frames the daemon walked up to find user code (positive = via RBP chain, negative = via RSP-scan fallback used at DebugBreak(); zero = current RIP was already in user code). When the EXE wasn't built with -V, the response carries error.message:"no_debug_section" with a fallback_hint — surface that and pivot to get_self + inspect_object for what you can still inspect.
{"method":"launch","params":{"exe":"..."}}
{"method":"continue"} // past loader BP into the message loop
// Find the form HWND.
{"method":"list_windows"}
// Response carries [{hwnd, class, title, rect, parent_hwnd}, ...].
// Pick the form by class:"TFormMain" or title:"My App".
// Set a BP at the click handler.
{"method":"set_breakpoint",
"params":{"source":"MainForm.pas","line":248}}
// Drive the UI. PostMessage queues even when target is paused -
// fine here, target is running.
{"method":"send_keys",
"params":{"hwnd":"<edit-hwnd>","text":"42"}}
{"method":"click_at",
"params":{"hwnd":"<button-hwnd>","x":50,"y":15,
"target":"OK"}}
Read the stream until breakpoint_hit arrives. The TEdit's text will already be "42" by the time the click handler runs, because WM_CHAR processed first. Inspect Self / locals as in R2.
Real workflow: target shows a "Save changes?" prompt and freezes your test until someone clicks. Three options, in order of preference:
// (a) Find and close the dialog.
{"method":"list_windows"}
// Match class:"TMessageForm" or title:"Confirm".
{"method":"post_message",
"params":{"hwnd":"<dialog-hwnd>","msg":16,"wparam":0,"lparam":0}}
// 16 = WM_CLOSE. Cleanest, runs the dialog's normal close path.
// (b) Press Esc inside the dialog (cancels via VCL default-cancel).
{"method":"send_keys",
"params":{"hwnd":"<dialog-hwnd>","text":""}}
// (c) Press Enter (default-affirm).
{"method":"send_keys",
"params":{"hwnd":"<dialog-hwnd>","text":"\n"}}
Multi-step: fill, click Next, fill, click Next, click Finish.
# pseudocode the agent runs around the wire
hwnd_form = list_windows() | grep "TFormWizard"
for step in [
{"field": "name", "value": "Test User", "next_button": "Next"},
{"field": "email", "value": "a@b.c", "next_button": "Next"},
{"field": "tier", "value": "premium", "next_button": "Finish"},
]:
hwnd_field = list_windows() | filter title==step.field # or by tab order
send_keys(hwnd_field, step.value)
sleep(0.2) # let the message pump catch up
click_at(target=step.next_button)
wait_for_event("breakpoint_hit" or "paused")
inspect_self()
Even with PostMessage, the target needs a few message-pump cycles to update its internal state (OnChange handlers, etc.) before the next click. 200ms is a safe lower bound for VCL.
When you've driven a UI to some state and want to see what the user would see — visual regressions, layout bugs, "is the dialog actually visible/correct" — capture_window returns a PNG of any HWND in the target.
# After R3 has driven inputtest to "hello\nclick OK",
# agent paused at the BP in the click handler.
resp = repl.send({"method":"capture_window",
"params":{"target":"TFormInputtest"}})
# resp.result has bytes_base64 (a PNG of the form pixels).
import base64, pathlib
png = base64.b64decode(resp["result"]["bytes_base64"]
.replace("\\/", "/")) # un-escape JSON
pathlib.Path("/tmp/inputtest-after-click.png").write_bytes(png)
Then read the file with the agent's vision capability — any modern AI agent with image-reading and a Read tool can describe the captured PNG. Particularly useful when:
The daemon must run on a host with an interactive desktop. Headless servers (Session 0, no display device) return blank PNGs — the kernel has no compositor surface to copy. Direct2D / Direct3D / Chromium-embedded controls bypass the GDI compositor and capture as black even with a display (affects games, embedded browsers; VCL native controls capture correctly). Never-painted windows (just-created HWND, no WM_PAINT yet) capture blank — VCL forms paint on first Show.
The spy methods solve a recurring agent problem: the user describes a UI bug in prose ("click Export, the dialog opens, type test in the path field, click Run, app crashes"), and you have to translate that into the right sequence of click_at / send_keys calls against components whose names you don't know yet. Just ask the human to record it.
--enable-spy (off by default; the low-level hooks see every input event on the daemon's desktop, so it's an explicit opt-in).rdbgc launch <host:port> <path-to-exe> --spy > repro.jsonl
They reproduce the bug once, then Ctrl-C. The JSONL contains the full event stream — debug events plus {"event":"input", ...} records for every click and keypress.event == "input"). Each input event has:
vcl_path — the dotted Owner.Component path you can match against list_windows output (Form1.PageControl1.tabExport.btnRunExport).vcl_class + class — Delphi class name + Win32 class.screen.x/y and client.x/y — coordinates for replay.hwnd — the HWND that received the input at recording time; do NOT use this directly for replay (the HWND is process-specific and won't survive a relaunch).vcl_path is set: call list_windows on your fresh launch, find the HWND whose component path matches, then send click_at (with the recorded client.x/y) or send_key_sequence (with the recorded vk / char).vcl_path is missing (non-VCL window — DirectDraw surface, hosted browser, etc.): fall back to send_input with the recorded screen.x/y. Less robust against window movement but works on anything.repro.jsonl against a fresh launch — no human in the loop after step 2.You can also call spy_start mid-session (interrupt the target, then {"method":"spy_start"} from a repl) to capture a new sequence — useful for confirming a regression hasn't reintroduced a bad input path, or for adding the user's "and then this also fails" follow-up to an existing repro.jsonl.
No wire method auto-arms it; even with --enable-spy on the daemon, the client must pass --spy at launch or send spy_start from a repl. So it's safe to assume an arbitrary session has no spy active unless you opted in for it.
The agent's superpower: run a launch+drive cycle in a tight loop until the bug fires, then drop into inspect mode automatically.
for attempt in range(50):
daemon = spawn_daemon()
repl = open_repl(daemon)
repl.send({"method":"launch","params":{"exe":"..."}})
repl.send({"method":"continue"})
# Drive the suspected reproducer.
repl.send(*recipe_R3_steps)
while event := repl.read_event(timeout=10):
if event.kind == "exception":
# Crash reproduced. Stop the loop, save state.
inspect_chain = repl.send({"method":"inspect_object",
"params":{"address":event["exception_object"],"max_depth":3}})
save_artifact(attempt, event, inspect_chain)
return
if event.kind == "exit_process":
# Clean exit this round; restart.
break
list_windows returns the TWinControl's HWND but not its Delphi object pointer — post_message is fire-and-forget and doesn't return values. To bridge HWND → Self pointer:
click_at / send_keys to fire the handler.get_self returns the form pointer (RCX on entry to a Delphi method holds Self).Once you have the form pointer, inspect_object walks the full class chain + published-field/property values cross-process.
inspect_object decodes field-backed properties cross-process by default. For method-backed properties (like TForm.Caption, TEdit.Text, TStrings.Count) pass eval_method_props:true — the daemon hijacks the paused thread, calls the getter on a 64 KB scratch stack, captures the return register, then restores. Per-prop failures surface error_reason; for AVs you also get target_exception_code, target_exception_addr, target_exception_data_addr, and target_exception_op ("read" / "write" / "execute") so you can tell which pointer was bad without parsing EXCEPTION_RECORD. Set eval_timeout_ms (default 1000) to bound a misbehaving getter.
For targeted reads, evaluate takes a Pascal-style path: {expr:"Self.Caption"}, {expr:"Form.Edit1.Text"}, or {expr:"0x<hex>.ComponentCount"}. Returns the same per-prop wire shape inspect_object emits, so the same renderers work. The root is either Self (resolved via get_self's heuristics on the paused frame) or a literal hex pointer. Each .ident step walks fields and properties — method-backed getters dispatch through the same cross-process RPC as inspect_object eval_method_props:true. v1 doesn't support indexing (Components[0]), arithmetic, type casts, method calls with args, or locals; those fail with parseable error_reason values.
list_threads shows every tid the daemon has tracked. The is_paused_thread flag marks the one whose context the other methods default to; pass an explicit tid parameter to get_registers to inspect a different thread.
Method dispatch failed because pause/run state didn't match. Check the last event you saw: if it was paused / breakpoint_hit / target_breakpoint / exception you're paused; if create_process followed by no pause and the target is consuming time, you're running. Fix by either continue-ing or waiting for the next pause event.
The daemon is generally honest about debugging state, but a target that's deadlocked won't give you events. Always wrap reads in a wall-clock timeout (the wire-scenarios harness uses python3 ... communicate(timeout=N)). On timeout: kill the SSH process group, kill the daemon explicitly via taskkill /F /IM rdbgd64.exe /T, kill known target binaries.
Window handles from list_windows are valid "for now". A target that closes and reopens a form gets a new HWND. Re-enumerate after major events (form-create / form-destroy in the target, mode-switching in the UI). Don't cache HWNDs across continue calls.
PostMessage doesn't wait for the message pump. Sending dozens of events in quick succession can flood the target's queue and lose WM_MOUSEMOVE coalescing. Pace at ~50–100ms between distinct UI actions; tighter for keystroke sequences.
Goal: a user reports that clicking Submit in inputtest.exe with an empty Email field crashes the app. Reproduce, capture stack + exception object, write up findings.
# Spawn a daemon on the Windows host.
rdbgd64.exe --listen 127.0.0.1:5902
# Drive a session from the agent's host (here, over SSH).
cat <<'EOF' | ssh windows-host "rdbgc.exe repl 127.0.0.1:5902"
{"method":"hello"}
{"method":"launch","params":{"exe":"C:/path/to/inputtest.exe"}}
{"method":"continue"}
EOF
Stream comes back:
{"id":1,"result":{"daemon":"rdbgd64","protocol":"1"}}
{"event":"create_process","pid":1234,"tid":5678,"image_base":"0x400000",...}
{"event":"paused","reason":"initial_loader_breakpoint",...}
{"id":2,"result":{"exit_code":0}} // launch ack-ish
{"id":3,"result":{"ok":true}} // continue ack
{"event":"debug_output","text":"inputtest started\n"}
... target running ...
Now the form is up. Drive it:
{"method":"list_windows"}
// pick form: class TFormInputtest, hwnd 0x00050aa6
// pick edit: parent matches form, class TEdit, title ""
// pick button: parent matches form, class TButton, title "Submit"
{"method":"send_keys","params":{"hwnd":"<edit>","text":""}}
// (no-op; edit was empty already, but explicit clears would go here)
{"method":"click_at","params":{"target":"Submit"}}
If the bug fires, the stream emits an exception event:
{"event":"exception","kind":"delphi","class":"EAccessViolation",
"exception_object":"0x1A2B3C40","frames":[
{"idx":0,"rip":"0x...","source":"InputtestMain.pas","line":52,
"symbol":"InputtestMain.TFormInputtest.SubmitClick","offset":17},
...]}
{"event":"paused","reason":"exception",...}
Capture state at the pause:
{"method":"inspect_object",
"params":{"address":"0x1A2B3C40","max_depth":3}}
{"method":"get_self"}
{"method":"inspect_object",
"params":{"address":"<self-from-above>","max_depth":2}}
{"method":"get_registers"}
Then either fix it (set_rip past the AV to keep the session going), or report and shutdown:
{"method":"shutdown"}
Write the findings: stack at fault, the form's FEmail field value (likely ''), and the suspected guard (if FEmail = '' then is missing in TFormInputtest.SubmitClick). Recommend the fix. Optionally cycle through R6 to confirm the bug is deterministic.
hello first. Confirms daemon liveness and the cost is negligible.set_breakpoint on an already-set BP returns the existing bp_id; safe to retry.paused.reason:"initial_loader_breakpoint" event before sending anything else.value_kind:"method" from "unreadable". The first means "we know what it is, can't read it without executing"; the second means "something went wrong reading memory." They have different fixes.Every method, every event, every field. The single source of truth for clients building their own driver.
Read →The interactive client. Useful when you want a human-driven session for comparison with the agent loop.
Read →Every method, every event, the full request/response shape. The canonical document for any client driving rdbg directly.
Read →The wire is stable in v1, additive only. Pair this guide with the rdbg-debugger plugin (for Claude Code) or write a thin MCP client around rdbg-mcp.exe for any other agent.