C+
Agent UI

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