AppKit agent surface
This example is the docs/examples/recipes/appkit_agent recipe from the C+
source tree. It builds an ordinary macOS AppKit window with a Save button, a
name field, and a decorative label, then opens that live NSView tree as an
agent surface.
The proof is the describe -> act -> refuse loop:
| Claim | How this example checks it |
|---|---|
| AppKit builds the real UI | window::Window, controls::Button, and controls::TextField create native controls |
| exposure is curated | ui::set_agent_id marks only the button and input as actionable |
describe_ui is structured |
agent_mcp returns a JSON-RPC snapshot of the surface tree |
| actions reach AppKit | actions/click fires the real Save handler and increments SAVES |
| text edits are versioned | a second set_text with the same base_version returns version_conflict |
| consent is enforced | the same request through auth::deny_all() returns consent denied |
Project layout
docs/examples/recipes/appkit_agent/
├── Cplus.toml
├── README.md
└── src/main.cplus
[package]
name = "appkit_agent"
version = "0.0.1"
edition = "2026"
[[bin]]
name = "appkit_agent"
path = "src/main.cplus"
[dependencies]
stdlib = "*"
json = "*"
appkit = "*"
agent_core = "*"
agent_appkit = "*"
agent_mcp = "*"
The important source
The recipe imports the AppKit backend, the framework-neutral authorization and event packages, and the MCP bridge:
import "appkit/runtime" as rt;
import "appkit/application" as application;
import "appkit/window" as window;
import "appkit/controls" as controls;
import "agent_appkit/agent_appkit" as ui;
import "agent_core/events" as events;
import "agent_core/auth" as auth;
import "agent_mcp/agent_mcp" as mcp;
The controls are normal AppKit objects. The only thing that makes a view actionable to an agent is the explicit tag:
let save: controls::Button = controls::Button::new(rect(360.0, 20.0, 100.0, 32.0));
save.set_title(#str_ptr("Save\0"));
save.set_on_click(on_save);
ui::set_agent_id(save.obj, "save-btn");
let name: controls::TextField = controls::TextField::new_input_field(rect(20.0, 180.0, 300.0, 24.0));
ui::set_agent_id(name.obj, "name-field");
let caption: controls::TextField = controls::TextField::new_label(rect(20.0, 140.0, 300.0, 20.0));
caption.set_string_value(#str_ptr("Your name:\0"));
The caption is deliberately untagged. It still appears in the tree as text, but it is not actionable. The surface is then opened from the live window:
let mut surf: ui::Surface = ui::open(win.obj);
let mut sub: events::Subscriber = events::subscriber(events::everything(), 8 as usize);
Describe
The recipe sends a JSON-RPC request through the MCP bridge:
{"method":"describe_ui","params":{},"id":1}
Expected response shape:
{"jsonrpc":"2.0","id":1,"result":[
{"id":"app/window#0", "role":"window", ...},
{"id":"save-btn", "role":"button", "actionable":true, ...},
{"id":"name-field", "role":"input", "actionable":true, ...},
{"id":".../text#2", "role":"text", "actionable":false, ...}
]}
The claim here is narrow: the agent gets a structured surface with stable ids for the tagged controls, not a raw screen scrape.
Act
Clicking the Save button goes through AuthGate, checks the surface rule for
save-btn, and invokes AppKit's real button action:
{"method":"actions/click","params":{"id":"save-btn"},"id":2}
Expected response:
{"jsonrpc":"2.0","id":2,"result":{"outcome":"allowed"}}
The recipe then checks the native handler state:
(the real Save handler fired: SAVES == 1)
Reject a stale text edit
Text edits carry the version the agent last saw. If the user or app changed the field after that snapshot, the edit is rejected instead of overwriting newer UI state.
{"method":"actions/set_text","params":{"id":"name-field","value":"Ada","base_version":0},"id":3}
{"method":"actions/set_text","params":{"id":"name-field","value":"oops","base_version":0},"id":4}
The first edit is allowed. The second uses the same stale base_version and is
rejected:
{"jsonrpc":"2.0","id":3,"result":{"outcome":"allowed"}}
{"jsonrpc":"2.0","id":4,"result":{"outcome":"version_conflict"}}
Refuse consent
The same describe_ui request through a closed gate is refused before it can
touch the UI:
{"method":"describe_ui","params":{},"id":5}
{"jsonrpc":"2.0","id":5,"error":{"code":-32001,"message":"consent denied"}}
Reproduce
From docs/examples/recipes/appkit_agent in the C+ source tree:
mkdir -p vendor
for p in stdlib json appkit agent_core agent_appkit agent_mcp; do
ln -s "$(git rev-parse --show-toplevel)/vendor/$p" "vendor/$p"
done
cpc build
./target/debug/appkit_agent
The recipe prints the describe_ui, click, set_text, stale-version, and
deny-all responses without entering the blocking AppKit event loop. A real app
builds the same tagged UI, calls app.run(), and serves the same surface to
agents on a background connection:
mcp::serve_uds(surf, sub, allow_external, "/tmp/cplus-agent.sock");
Those checks prove the important part of the claim: the app exposes a curated
agent surface over agent_appkit and agent_mcp, and the bridge does not bypass
the app's consent, versioning, or action rules.
‹ Back to all examples