Skip to content

LocalKinAI/kinax-go

Repository files navigation

kinax-go

Pure-Go binding to the macOS Accessibility (AX) API.

Navigate and manipulate the system-wide UI tree — inspect running applications, find buttons by semantic identity, read text field contents, click elements without hardcoded pixel coordinates.

Single binary, zero cgo, go install-able. Built on AXUIElement via purego + an embedded companion dylib — the same pattern as sckit-go, kinrec, and input-go.

kinax-go is part of KinKit — the pure-Go macOS system library family powering the LocalKin agent swarm. It pairs with input-go: kinax-go sees the UI, input-go moves it.

go install github.com/LocalKinAI/kinax-go/cmd/kinax@latest

kinax tree --bundle com.apple.Safari --depth 4
kinax find AXButton --bundle com.apple.Safari
kinax click "Continue"
kinax at-point 200 100

Features

  • Element tree navigationChildren, Parent, Windows, FocusedWindow, FocusedElement, AttributeElement.
  • Typed attribute readersAttribute (string), AttributeInt, AttributeBool, AttributePoint (CGPoint), AttributeSize (CGSize), AttributeElements (array).
  • Attribute + action introspectionAttributeNames, ActionNames return live lists from the element.
  • Semantic searchFindFirst / FindAll with composable matchers (MatchRole, MatchTitle, MatchTitleContains, MatchIdentifier, MatchAll, MatchAny).
  • Hit testingElementAtPoint(x, y) returns whatever AX element is at the given global coords (what Accessibility Inspector shows when you hover).
  • App targetingFocusedApplication, ApplicationByPID, ApplicationByBundleID, SystemWide.
  • Action + writePerform("AXPress"), SetString, SetBool.
  • Menu navigation (v0.4) — Element.NavigateMenu("File > New Window") walks the AX menu tree and presses the leaf; MenuItemShortcut() reads the keyboard equivalent (e.g. ⌘⇧N) without firing the action.
  • Push-based event subscriptions (v0.3) — Observer + Subscribe deliver AXFocusedUIElementChanged / AXValueChanged / etc. via a Go channel; CFRunLoop is internalized.
  • Batch attribute fetch (v0.2) — Element.GetMany(names...) issues a single AXUIElementCopyMultipleAttributeValues call instead of N round-trips.
  • No cgo: downstream projects stay pure Go. The ObjC companion dylib is //go:embedded (~70 KB universal arm64+x86_64) and extracted to ~/Library/Caches on first call.

Install

# CLI
go install github.com/LocalKinAI/kinax-go/cmd/kinax@latest

# Library
go get github.com/LocalKinAI/kinax-go

Requires macOS 12+ and Go 1.22+.

Permission

macOS requires the invoking binary to be listed in System Settings → Privacy & Security → Accessibility for AX* calls to succeed. Unlike input-go (which silently no-ops without permission), kinax-go returns real errors — every accessor will fail until permission is granted.

if err := kinax.RequireTrust(); err != nil {
    kinax.PromptTrust() // shows system dialog
    log.Fatal("grant Accessibility permission, then rerun")
}

Library usage

package main

import (
    "fmt"
    "log"

    "github.com/LocalKinAI/kinax-go"
)

func main() {
    if err := kinax.Load(); err != nil {
        log.Fatal(err)
    }
    if err := kinax.RequireTrust(); err != nil {
        log.Fatal(err)
    }

    // Attach to Safari (must be running)
    app, err := kinax.ApplicationByBundleID("com.apple.Safari")
    if err != nil { log.Fatal(err) }
    defer app.Close()

    // Walk all windows
    wins, _ := app.Windows()
    for _, w := range wins {
        t, _ := w.Title()
        fmt.Println("window:", t)
        w.Close()
    }

    // Find every text field, print its value
    fields := app.FindAll(kinax.MatchRole(kinax.RoleTextField), 30)
    for _, f := range fields {
        v, _ := f.Value()
        fmt.Println("field:", v)
        f.Close()
    }

    // Click the "New Tab" button
    if btn, ok := app.FindFirst(kinax.MatchTitle("New Tab"), 20); ok {
        defer btn.Close()
        btn.Perform(kinax.ActionPress)
    }
}

CLI usage

# Dump the AX tree of the focused app (default target)
kinax tree --depth 4

# Dump a specific app's tree
kinax tree --bundle com.apple.Safari --depth 5
kinax tree --pid 1234

# Inspect one attribute of the app element
kinax attr AXTitle --bundle com.apple.Safari

# List every attribute or action the app exposes
kinax attrs --focused
kinax actions --focused

# Find every button (optionally with a specific title)
kinax find AXButton --bundle com.apple.Safari
kinax find AXButton "New Tab" --bundle com.apple.Safari --depth 25

# Click a button by title
kinax click "Continue"
kinax click "OK" --role AXButton --bundle com.apple.SystemPreferences

# Hit-test a screen coordinate
kinax at-point 200 100
# → AXMenuBar    Apple

# Permission
kinax trust              # prints 1 or 0
kinax trust --prompt     # shows system dialog

Memory ownership

Every *Element returned by kinax-go wraps a retained CFTypeRef. The caller must call (*Element).Close — forgetting to leaks a handle for the process lifetime.

Traversal helpers (FindFirst, FindAll) return fresh handles; any siblings they walk past are closed automatically. You only need to close what's returned to you.

// Correct
wins, _ := app.Windows()
for _, w := range wins {
    defer w.Close()
}

// Also correct — find returns a fresh handle
if btn, ok := app.FindFirst(kinax.MatchTitle("OK"), 20); ok {
    defer btn.Close()
    btn.Perform(kinax.ActionPress)
}

Combining with input-go

kinax finds the element; input drives the cursor there. This is the natural UI-automation loop:

import (
    "github.com/LocalKinAI/kinax-go"
    "github.com/LocalKinAI/input-go"
)

btn, _ := app.FindFirst(kinax.MatchTitle("Save"), 20)
defer btn.Close()

pos, _ := btn.Position()
size, _ := btn.Size()
cx := float64(pos.X + size.X/2)
cy := float64(pos.Y + size.Y/2)

input.MoveSmooth(ctx, cx, cy, 300*time.Millisecond)
input.ClickAt(ctx, cx, cy)

For many cases btn.Perform(kinax.ActionPress) is enough — clicking via AX is faster and doesn't require Accessibility permission on the binary that runs input.Click (though it does require it on the one that runs kinax.Perform). Use input-go for pixel-level drag gestures, hover highlights, and scrolling.

How it works

kinax-go follows the embedded dylib pattern documented in Paper #9 of localkin.dev/papers.

Go code  ─── purego.Dlopen ────► libkinax_sync.dylib (embedded)
                                     │
                                     └──► AXUIElement* APIs
  • objc/kinax_ax.m — ~450 LOC ObjC shim exposing 20 C-ABI functions (kinax_element_attr_string, kinax_element_perform, etc.).
  • internal/dylib/libkinax_sync.dylib — universal Mach-O, committed.
  • Opaque uintptr handles for elements; CFRetain on the ObjC side, CFRelease via kinax_element_release when Go Closes.
  • JSON encoding for list attributes (attribute names, action names) — avoids shipping a full CF→Go type system across the FFI.

Known limitations (current as of v0.4)

  • macOS only. The Accessibility API is macOS-specific — no cross-platform ambitions.
  • Numeric attributes only as int64. Float-valued attributes (AXValue on sliders) currently string-stringify. AttributeFloat planned for v0.5.
  • CGPoint/CGSize setters not exposed yet. Window-move via SetPosition / SetSize planned for v0.5.
  • Single main thread assumption for some CF calls. In practice kinax works from any goroutine because we don't use CFRunLoop except inside the [Observer] subsystem.
  • Tested only on macOS 26.3 arm64 so far; Intel + macOS 14/15 verification pending CI.

Roadmap

  • v0.2Element.GetMany batch attribute fetch via AXUIElementCopyMultipleAttributeValues. Shipped 2026-04-19.
  • v0.3 — Push-based AX event subscriptions (Observer + Subscribe + Next / Events channel). Shipped 2026-04-29.
  • v0.4Element.NavigateMenu(path) + MenuItemShortcut()
    • menu/action constants. Shipped 2026-05-08.
  • v0.5 (planned) — typed numeric attributes (AttributeFloat), geometry setters (SetPosition / SetSize), WaitForWindow high-level convenience.
  • v0.6 (planned) — FindMenuItem(path) (split off from NavigateMenu so callers can introspect the leaf without firing the action; needed by kinclaw's ui shortcut reading path), Element.Snapshot() + Snapshot.Diff() for state-change verification.

Contributing

git clone https://github.com/LocalKinAI/kinax-go
cd kinax-go
make dylib            # rebuild universal Mach-O after ObjC changes
make test             # unit tests (no Accessibility permission needed)
make test-integration # requires Accessibility permission
make lint             # go vet + staticcheck + golangci-lint

License

MIT. See LICENSE.

See also

About

Pure-Go binding to the macOS Accessibility (AX) API. Navigate and manipulate the system UI tree via AXUIElement. Zero cgo, embedded dylib.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors