Agent guide

Drive rdbg from an autonomous agent.

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.

What rdbg gives an agent

  • A persistent JSON pipe to a daemon driving a target via the Win32 Debugging API.
  • Commands to launch, set breakpoints (SW + HW), step, read memory and registers, and inspect Delphi objects — full RTTI walk including extended-RTTI public/private/protected, record fields, dyn-arrays, strings.
  • Async events: paused, breakpoint_hit, target_breakpoint, exception, debug_output, exit_process.
  • HWND enumeration + input injection — 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.
  • Input-spy mode — the inverse of input injection. 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.
  • DLL symbols beyond the launched exe — frame addresses inside 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.

Connecting

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

Wrap reads in a wall-clock timeout

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".

Mental model: pause-loop semantics

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.

Key implication

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.

Method cheat-sheet

The full reference is in docs/protocol.md; this is the agent-relevant slice.

Group Method When Notes
Lifecyclehelloalwayssanity check, no side effects
Lifecyclelaunchonce / sessionpath is forward-slashed; daemon spawns target paused at loader BP
Lifecycleattachonce / sessionby pid or name (substring); kernel synthesizes the avalanche, lands paused
Lifecyclelist_processesalwaysenumerate running PIDs + .map / .dpr discovery
Lifecycleshutdownalwaysterminates target + daemon
Lifecycledetach (planned)pausedleave target running, exit daemon
Flowcontinuepausedresume
Flowstep_overpausedline-aware, skips CALL
Flowstep_intopausedsingle-instruction including into callees
Flowrun_topausedone-shot temp BP at addr or src:line
Flowset_rippausedmove execution point (e.g. skip past AV)
BPsset_breakpointalwaysby addr OR source+line; kind:"hw" for HW BPs
BPsclear_breakpointalwaysby address OR bp_id
BPslist_breakpointspausedenumerate active BPs
Stateinspect_objectpausedfull RTTI walk; max_depth controls eager vs lazy
Stateread_memorypausedup to 64 KiB, hex-encoded
Stateread_stringpausedunicode/ansi/short kinds
Stateread_dyn_arraypausedraw hex; client decodes
Stateget_registerspausedx64 GPR set + eflags; tid optional
Stateget_selfpausedRCX heuristic for Self of method frame
Stateinspect_localspausedparams + stack-locals; needs target built with -V (TD32/TDS in EXE)
Statelist_threadspausedtids + last-known RIP per thread
UIlist_windowspausedenumerate target HWNDs
UIpost_messagepausedlow-level WM_* poke (named or numeric msg)
UIsend_keyspausedUTF-16 → WM_KEYDOWN/CHAR/UP triplets
UIsend_key_sequencepausedexplicit {vk, down, char?} events for modifiers
UIclick_atpausedWM_MOUSEMOVE/LBUTTONDOWN/UP at hwnd-relative coord
UIcapture_windowpausedBitBlt 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.

Recipes

Operational patterns from least to most agent-driven. Each one is self-contained — mix and match for your scenario.

R0 Discover and attach to a running target

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.

map_path / source_dir / dpr_path are best-effort

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.

R1 Launch and watch

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.

R2 Set BP at a handler, observe inputs at hit

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.

R3 Drive the UI to fire a handler

{"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.

R4 Dismiss a modal dialog blocking your path

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"}}

R5 Walk through a wizard

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()

The 200ms sleep matters

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.

R5b Inspect the visual state

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:

  • A click handler raises and you want to know whether the user would have seen the broken dialog or just the error.
  • The agent suspects a visual regression after a code change — capture before+after, diff visually.
  • A multi-step wizard is partway through and you want to confirm the right form is on top, the right field is focused, etc.

Capture limitations

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.

R5c Bug-reproduction handoff (input-spy mode)

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.

  1. Daemon must be started with --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).
  2. Ask the human to capture a clean repro in their RDP session:
    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.
  3. Read the JSONL (filter to 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).
  4. Replay deterministically. For each input event:
    • If 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).
    • If 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.
  5. Iterate. Each fix attempt re-runs the same 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.

Spy is never on without an explicit request

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.

R6 Reproduce an intermittent crash

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

State inspection patterns

Find the Delphi object behind a HWND

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:

  1. Set a BP at a method on the form (anything that runs early — often the click handler line is fine).
  2. Use click_at / send_keys to fire the handler.
  3. At the BP, 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.

Read a property's live value

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.

Resolve a path expression in one shot

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.

Multi-thread targets

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.

Error and timeout patterns

"method not valid while paused"

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.

Hung pipe

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.

HWND staleness

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.

Rate-limiting

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.

End-to-end worked example

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.

Tips for autonomous loops

  • Always send hello first. Confirms daemon liveness and the cost is negligible.
  • Keep wire requests idempotent where possible. set_breakpoint on an already-set BP returns the existing bp_id; safe to retry.
  • Save the response stream verbatim. Future-you (or another agent) reading the artifact will care more about the literal JSON than your interpretation of it.
  • Don't assume launch succeeded silently. Always wait for at least one paused.reason:"initial_loader_breakpoint" event before sending anything else.
  • Distinguish 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.
  • For UI driving, log every HWND you discover with its class + title. The "Why didn't my click do anything?" debugging is almost always "you posted to the wrong HWND" or "HWND got reused after a form recreate."

See also

Wire protocol

Every method, every event, every field. The single source of truth for clients building their own driver.

Read →

GUI manual

The interactive client. Useful when you want a human-driven session for comparison with the agent loop.

Read →

Wire-protocol reference

Every method, every event, the full request/response shape. The canonical document for any client driving rdbg directly.

Read →

Building an agent on top of rdbg?

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.

Get the plugin Quickstart