Hand off bounded desktop subtasks to a fast decision model. No frontier model needed for every click.
arc-cua lets a planner or CUA agent hand off bounded desktop subtasks to a fast decision model (JEV) that executes the UI loop. The planner owns intent — what to do, what text to use, what counts as success. The executor owns observation, fast decisions, freshness checks, native UI execution, and bounded termination.
from arc_cua import execute_payload
result = execute_payload(executor, {
"goal": "Play Get Lucky by Daft Punk in Spotify",
"inputs": {"search_query": "Get Lucky Daft Punk"},
"verification": ["Spotify shows Get Lucky as the current track"],
"constraints": ["Do not modify the user's library"],
"max_actions": 15,
})
# result: {"status": "SUBTASK_COMPLETE", "actions_taken": 4}
Any GPT, Claude, Gemini, local model, or deterministic planner can generate that payload. The planner deliberately lives outside the package.
Machine-readable documentation optimized for LLM context windows:
Currently macOS-first. Requires Python 3.12+.
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e '.[macos]'
export TYPESAFE_API_KEY=...
The terminal or editor running Python needs both:
Restart the terminal after granting permissions if necessary.
from arc_cua import DesktopExecutor, Subtask, RuntimeConfig
from arc_cua.backends import MacOSApp, MacOSHybridBackend
from arc_cua.policies import TypeSafeJevPolicy
pid = MacOSApp.from_bundle_id("com.spotify.client").pid
executor = DesktopExecutor(
MacOSHybridBackend(pid),
TypeSafeJevPolicy(),
config=RuntimeConfig(timeout_s=30),
)
task = Subtask(
goal="Search for Gaussian Blur in the effects panel",
verification=("Effects panel shows Gaussian Blur",),
inputs={"effect_name": "Gaussian Blur"},
constraints=("Do not modify any clip",),
)
result = executor.run(task)
print(result.status, result.actions_taken)
from arc_cua import (
ActionKind, Decision, DesktopElement, DesktopExecutor,
DesktopSnapshot, Subtask, TerminalKind,
)
from arc_cua.backends import StateMachineBackend
from arc_cua.policies import ScriptedPolicy
def make_snapshot(state):
return DesktopSnapshot(
application="App", window="Main", revision=state["q"],
elements=(
DesktopElement(
id="search", role="text_field", name="Search",
value=state["q"], actions=(ActionKind.TYPE_TEXT,),
source="demo",
),
),
)
def transition(state, action):
if action.kind == ActionKind.TYPE_TEXT:
state["q"] = action.value
backend = StateMachineBackend({"q": ""}, make_snapshot, transition)
policy = ScriptedPolicy([
Decision(kind=ActionKind.TYPE_TEXT, target_id="search", input_key="query"),
Decision(terminal=TerminalKind.SUBTASK_COMPLETE),
])
task = Subtask(
goal="Search for cats",
verification=("Search field contains cats",),
inputs={"query": "cats"},
)
result = DesktopExecutor(backend, policy).run(task)
assert result.status == TerminalKind.SUBTASK_COMPLETE
planner / LLM
|
| Subtask(goal, inputs, verification, constraints)
v
+------------------------+
| arc-cua |
| |
| observe desktop |
| (AX + Vision OCR) |
| v |
| build legal |
| action space |
| v |
| JEV decision |<------+
| v | |
| freshness guard | |
| v | |
| execute UI action | |
| v | |
| wait for UI settle |-------+
+------------+-----------+
|
v
SUBTASK_COMPLETE / BLOCKED / NEEDS_AGENT
|
v
planner
The upstream agent decides what needs to happen, what literal text may be used, what must not happen, and what counts as success. JEV chooses which element to target and which operation to perform — but never invents arbitrary text. Literal values always originate from the agent via Subtask.inputs.
JEV is the decision backend that powers the action loop. Given structured desktop state (elements, roles, values), it selects the next UI operation from a dynamically built action space — it can only pick targets and operations the current desktop actually exposes. JEV is accessed through TypeSafe. One JEV call can resolve the operation and its parameters in parallel (speculative fan-out).
Accessibility (AX)
Semantic controls: buttons, fields, menus, roles, values, native actions. Covers well-built apps with proper accessibility trees.
Apple Vision OCR
Visible screen text with bounding boxes. Covers apps with incomplete accessibility — Electron apps, custom canvases, web views.
Both normalize into DesktopElements that JEV reasons over. JEV receives structured elements and IDs, not screenshots.
Every actionable target has a semantic or visual guard (SHA-256 fingerprint of element state). Before executing a chosen mutation, the backend checks that the target still corresponds to the UI state JEV observed. A stale action is never blindly replayed.
After a mutating action, the runtime waits until the desktop has reacted and gone quiet, then observes it once. On macOS it settles on a cheap visual probe (the target app's front window plus a small thumbnail of its own windows) instead of repeated AX + OCR observations. The decision model decides what to do; the runtime decides when the UI is ready to reason over again.
| Status | Meaning |
|---|---|
SUBTASK_COMPLETE | Verification criteria appear satisfied |
BLOCKED | Cannot make progress with available operations |
NEEDS_AGENT | Higher-level reasoning required or action budget reached |
The JEV policy checks each verification criterion separately. A proposed completion becomes NEEDS_AGENT with a reason if any criterion is contradicted or cannot be established. These are model judgements; use RuntimeConfig.verify or caller-side validation for an independent check.
All public types are available from the top-level arc_cua package:
from arc_cua import (
ActionKind, Bounds, Decision, DesktopElement, DesktopExecutor,
DesktopSnapshot, ExecutableAction, ExecutionResult, RuntimeConfig,
StepEvent, Subtask, TerminalKind, VerifyFn,
execute_payload, result_to_dict, subtask_from_dict,
)
Contract supplied by the external agent/planner. The executor never invents verification criteria or free-form text.
Supply a keyboard chord and its meaning to JEV through the Python or JSON API:
task = Subtask(
goal="Save the current document",
verification=("The document has no unsaved changes",),
shortcuts={"MOD+S": "Save the current document in this editor"},
)
JEV chooses from the defaults plus the supplied shortcuts when it selects HOTKEY. Defaults are unchanged; extra choices apply only to this subtask. Supplying an existing default chord gives it a contextual description.
Chords use uppercase keys and one or more MOD, CTRL, ALT, or SHIFT modifiers. MOD means Command on macOS. Keys include A-Z, 0-9, F1-F20, navigation keys, and named punctuation keys; see the full reference for the vocabulary. The macOS backend uses US/ANSI physical key positions. Each shortcut is one chord.
Invalid declarations are rejected when the subtask is created. Runtime validation also rejects undeclared non-default hotkeys, including those emitted by custom policies.
A normalized, currently observable UI element. IDs are stable for the session lifetime and are never model-invented.
Immutable capture of the current desktop state.
element(element_id) -> DesktopElement — O(1) lookup by ID. Raises KeyError.
compact() -> dict — Planner-friendly summary (visible elements only).
| Value | Target | Additional |
|---|---|---|
CLICK | yes | — |
DOUBLE_CLICK | yes | — |
RIGHT_CLICK | yes | — |
TYPE_TEXT | yes | input_key (resolved from Subtask.inputs) |
SET_VALUE | yes | input_key (resolved from Subtask.inputs) |
PRESS_KEY | no | key (ENTER, ESCAPE, TAB, ...) |
HOTKEY | no | hotkey (MOD+C, MOD+V, ...) |
SCROLL | no | scroll_direction (UP, DOWN, LEFT, RIGHT) |
DRAG_TO | yes | secondary_target_id |
DRAG_BY | yes | drag_dx, drag_dy |
MOD means Cmd on macOS, Ctrl elsewhere.
The core executor. Takes a backend and a policy, runs subtasks.
executor = DesktopExecutor(backend, policy, config=RuntimeConfig())
result = executor.run(task) # Returns ExecutionResult
for event in executor.run_iter(task): # Yields StepEvent per cycle
print(event.step, event.action)
executor.cancel() # Thread-safe stop signal
run() executes the subtask and returns the result. run_iter() is a generator yielding a StepEvent after each decision cycle for live observability. cancel() can be called from another thread to stop execution after the current action.
VerifyFn = Callable[[DesktopSnapshot, Subtask], bool]
When set on RuntimeConfig, called before accepting SUBTASK_COMPLETE. If it returns False, the result becomes NEEDS_AGENT.
Emitted by run_iter() after each decision cycle.
terminal property returns True when result is set.
actions_taken property returns len(history).
goal must be a non-empty string. verification must be a non-empty array of non-empty strings, and constraints must be an array of non-empty strings. Even one criterion needs an array: "verification": ["The folder exists"]. Bare strings are rejected before a provider request.
inputs maps names to literal strings, finite numbers, or booleans. Supply every value that needs to be typed here. max_actions must be a positive integer; metadata and shortcuts must be objects. Invalid fields raise descriptive errors without silent type conversion. Use uppercase chords such as MOD+SHIFT+N; unmodified Return is the built-in key ENTER.
JEV requests share one element table and subtask across questions. Target choices reference observed IDs, preserving the element facts and available choices while reducing repeated data. Provider token-limit failures report max_tokens_exceeded without executing an action.
Three functions for the agent-facing JSON contract:
# Parse agent payload into Subtask
task = subtask_from_dict({
"goal": "...", "verification": ["..."],
"inputs": {...}, "constraints": [...], "max_actions": 15,
"shortcuts": {"MOD+S": "Save the current document in this editor"},
})
# Convert result to planner-friendly dict
d = result_to_dict(result)
# {"status", "actions_taken", "reason", "observations", "history", "final_snapshot"}
# One-shot convenience
d = execute_payload(executor, payload)
All inherit from JevDesktopError(RuntimeError):
| Error | Meaning | Runtime behavior |
|---|---|---|
StaleDesktopState | Target changed between observation and execution | Caught, triggers re-observation |
InvalidDecision | Policy returned an illegal action | Propagates to caller |
UnsupportedDesktopAction | Backend does not support the action kind | Propagates to caller |
The default production backend. Combines Accessibility and Apple Vision OCR into a single element stream. Handles modal detection, OCR deduplication, and coordinate-based execution for OCR targets.
Acts on one app, given by process ID, in the background: the user's pointer does not move, the window is not raised and the front app does not change. As a context manager it also reaches a minimized window or a hidden app's windows, out of sight. Raises TargetUnavailable once the app quits or has no usable window.
from arc_cua.backends import MacOSApp, MacOSHybridBackend
with MacOSHybridBackend(MacOSApp.from_bundle_id("com.apple.TextEdit").pid) as backend:
...
From any language: arc-cua run reads one JSON request (app, subtask, provider) on standard input, prints one JSON line per action and a final result line, and exits. See the full reference.
One Chrome tab through the DevTools protocol, on macOS, Linux and Windows. Reads visible controls and text from the DOM in one call (about 9 ms on a large article), including shadow DOM and same-origin iframes. Input does not move the real pointer or need window focus.
from arc_cua.backends import ChromeBackend # pip install 'arc-cua[browser]'
backend = ChromeBackend.launch("https://example.com")
Pure Accessibility backend. Traverses the AX tree of one app, given by process ID, with background input.
from arc_cua.backends import MacOSAXBackend
backend = MacOSAXBackend(pid, max_elements=1200, max_depth=18, cache=False)
Supports CLICK, DOUBLE_CLICK, RIGHT_CLICK, TYPE_TEXT, SET_VALUE, PRESS_KEY, HOTKEY, SCROLL. Reads only what is on screen. cache=True re-reads only elements the app reports as changed, so repeat observations take a few milliseconds.
Deterministic backend for tests and demos. No OS dependencies.
from arc_cua.backends import StateMachineBackend
backend = StateMachineBackend(
initial_state={"key": "value"},
snapshot_factory=my_snapshot_fn, # (state) -> DesktopSnapshot
transition=my_transition_fn, # (state, action) -> None
)
Production policy. Builds a dynamic action space from the current DesktopSnapshot and sends it to the JEV API.
from arc_cua.policies import TypeSafeJevPolicy
policy = TypeSafeJevPolicy(
api_key="...", # or TYPESAFE_API_KEY env var
model="jev-latest", # or TYPESAFE_MODEL env var
timeout_s=12.0,
)
Deterministic policy for tests. Consumes decisions in order.
from arc_cua.policies import ScriptedPolicy
policy = ScriptedPolicy([
Decision(kind=ActionKind.CLICK, target_id="btn"),
Decision(terminal=TerminalKind.SUBTASK_COMPLETE),
])
Extend arc-cua by implementing these protocols. No base class required — structural subtyping.
class DesktopBackend(Protocol):
def observe(self) -> DesktopSnapshot: ...
def is_fresh(self, snapshot: DesktopSnapshot, action: ExecutableAction) -> bool: ...
def execute(self, snapshot: DesktopSnapshot, action: ExecutableAction) -> None: ...
observe() captures current desktop state. is_fresh() checks the target guard still matches reality. execute() performs the action — raise StaleDesktopState if the target changed, InvalidDecision if the action is illegal.
class DecisionPolicy(Protocol):
def decide(
self, *,
subtask: Subtask,
snapshot: DesktopSnapshot,
history: Sequence[ActionRecord],
) -> Decision: ...
Return Decision(kind=..., target_id=...) for an action, or Decision(terminal=...) to end execution.
Implement observe(), is_fresh(), and execute(). Return DesktopElements with stable IDs. Use semantic_guard() for freshness checks.
class MyBackend:
def observe(self) -> DesktopSnapshot:
elements = self._scan_ui()
return DesktopSnapshot(
application="MyApp", window="Main",
revision=self._compute_revision(elements),
elements=tuple(elements),
)
def is_fresh(self, snapshot, action) -> bool:
if action.target_id:
current = self.observe().element(action.target_id)
return current.semantic_guard() == action.target_guard
return True
def execute(self, snapshot, action) -> None:
# Perform the UI action
...
Implement decide(). Return a Decision with either kind (action) or terminal (end).
arc-cua uses stdlib logging. Enable debug output:
import logging
logging.basicConfig(level=logging.DEBUG)
Loggers: arc_cua.runtime, arc_cua.backends.macos_ax, arc_cua.backends.macos_hybrid, arc_cua.backends.macos_ocr, arc_cua.policies.typesafe.
Runtime
httpx[http2] >= 0.28, < 1
macOS extras
pyobjc-framework-ApplicationServices >= 11
pyobjc-framework-Cocoa >= 11
pyobjc-framework-Quartz >= 11
pyobjc-framework-Vision >= 11