Adding New Connectors

This guide walks through creating a connector for a new AI agent.

Prefer Lua connectors for all agents. Lua scripts are easier to write, can be updated at runtime via the praxis TUI's Settings → Agents tab without recompiling, and share common helpers for executable discovery, version extraction, and multi-user support. For browser-based agents, the praxis.devtools Lua library and praxis.cdp_* native API provide Chrome DevTools Protocol support (see M365 Copilot as an example). Use Rust connectors only when you need OS-level capabilities that aren't exposed through the Lua API.

Lua agent scripts live in agents/ at the project root and are embedded into binaries at build time. They can also be uploaded via the praxis TUI's Settings → Agents tab.

Tip: Scripts uploaded or created through the TUI are tagged as user scripts and won't be overwritten by Praxis updates. If you want to customize a built-in script, create a copy with your changes and disable the original.

CLI Agents vs Browser-Based Agents

For CLI agents (e.g. Claude Code, Gemini CLI), use praxis.command_run / praxis.command_run_handle to spawn processes and interact via stdin/stdout. For agents that support the Agent Client Protocol (ACP), use the praxis.acp_* APIs for long-lived subprocess sessions with real-time streaming (see ACP Sessions below).

For browser-based agents (e.g. M365 Copilot), use the praxis.devtools library and praxis.cdp_* native API to drive the agent via Chrome DevTools Protocol. See DevTools-Based Agents below.

Script Structure

A Lua connector returns a table with name, short_name, and callback functions. For CLI agents, follow the same high-level structure used by agents/gemini.lua:

local helpers = require("praxis.helpers")

local AGENT_NAME = "Example AI"
local AGENT_SHORT_NAME = "exampleai"

local function verify_binary(path)
  local result = praxis.command_run({ program = path, args = { "--version" } })
  if result.success then
    local version = (result.stdout or ""):match("(%d[%d%.%-a-zA-Z]*)")
    return true, version
  end
  return false, nil
end

local function pick_path()
  return helpers.find_executable({
    name = "exampleai",
    global_dirs = {
      default = { "/usr/local/bin", "/usr/bin" },
    },
    home_dirs = {
      default = { "${HOME}/.local/bin" },
      windows = { "${USERPROFILE}\\.local\\bin" },
    },
    verify = verify_binary,
  })
end

return {
  name = AGENT_NAME,
  short_name = AGENT_SHORT_NAME,

  fingerprint = function(_ctx)
    local process_path, process_version = pick_path()
    return {
      available = process_path ~= nil,
      process_path = process_path,
      version = process_version,
    }
  end,

  -- Traffic interception domains are no longer declared per-agent. To
  -- capture traffic for this connector, add an entry in
  -- Settings → Intercept that references AGENT_SHORT_NAME.

  -- Optional but recommended: reconnaissance.
  -- Use run_standard_recon + declarative recon_config.
  recon = function(ctx)
    return helpers.run_standard_recon(ctx, recon_config)
  end,

  -- Required for sessions.
  create_session = function(ctx)
    return {
      handle = praxis.uuid_v4(),
      process_path = ctx.process_path,
      working_dir = ctx.working_dir,
      yolo_mode = ctx.yolo_mode == true,
    }
  end,

  session_transact = function(_ctx, state, prompt)
    local result = praxis.command_run_handle({
      program = state.process_path,
      args = { "--prompt", "-" },
      cwd = state.working_dir,
      stdin = prompt,
    }, state.handle)
    return { response = result.stdout or "", state = state }
  end,

  session_close = function(_ctx, state)
    -- Cleanup if needed.
  end,
}

Recommended pattern for recon config (same style as Gemini/Cursor/ClaudeCode):

local recon_config = {
  home_dir = ".exampleai",

  home_configs = {
    { path = ".exampleai/settings.json", type = "global_settings", mcp = true },
  },

  project_markers = { "/.exampleai/settings.json" },

  project_configs = {
    { path = ".exampleai/settings.json", type = "project_settings", mcp = true },
  },

  mcp_parsers = {
    default = helpers.parse_mcp_from_json_flexible,
  },

  auth_check = path_has_valid_auth,
  session_discovery = discover_sessions_for_home,

  session_fns = {
    create = run_create_session,
    transact = run_session_transact,
    close = run_session_close,
  },
}

Key points:

  • recon receives a context object: recon = function(ctx) ... end
  • The result must be shaped as { config = { items, project_paths }, tools = { mcp_servers, skills, internal_tools }, sessions = { items } }. helpers.run_standard_recon handles this for the standard pipeline
  • Semantic recon (driven by ctx.is_semantic) populates tools.internal_tools by interrogating the agent through config.session_fns
  • Avoid mutable global process state; return process_path from fingerprint and consume it via ctx.process_path
  • Every ACP session gets its own Lua VM loaded from compiled bytecode, so Lua globals are not shared between sessions. Keep all per-session state in the state table returned by create_session — do not stash it in module-level Lua variables expecting to read it back in session_transact.

Reading Session Content (read_session_content)

A connector can optionally define a top-level read_session_content(session_file) function. The loader checks whether it exists and, if so, calls it instead of reading session_file from disk directly — use this when a discovered session's history isn't a plain text/JSONL file. The Cursor connector (agents/cursor.lua) uses it to decode its SQLite-backed chat store: session_file arrives as a virtual "<db_path>::<session_id>" path, and the hook queries the database, walks the content-addressed blob chain, and returns the reconstructed transcript as JSONL.

read_session_content = function(session_file)
  local db_path = string.match(session_file, "^(.+)::.+$")
  if not db_path then
    return nil
  end

  -- Query the SQLite store at db_path, resolve the blob chain, and
  -- return the reconstructed transcript. See agents/cursor.lua for
  -- the full implementation.
end,

If the function is not defined, Praxis falls back to reading session_file directly from disk.

helpers.find_executable Config

The find_executable helper searches for an agent binary in 4 phases:

  1. PATH search via praxis.find_executables(name) - searches the system PATH
  2. Global directories - explicit absolute paths (e.g. /usr/local/bin)
  3. Home directories - templates expanded per user home (e.g. ${HOME}/.local/bin)
  4. Glob patterns - for version manager installations (e.g. nvm, mise)

On Windows, .cmd is tried before .exe for each directory. The verify function receives a candidate path and returns (passed, version).

Config fields:

  • name (string) - executable name for PATH search and path construction
  • global_dirs (table) - { default = {...}, windows = {...} } absolute directories
  • home_dirs (table) - same shape, directory templates with ${HOME} etc.
  • glob_paths (table) - full glob patterns (wildcards embedded in path)
  • verify (function) - fn(path) -> passed, version

OS resolution: tbl[os_name] or tbl.default or {} where os_name is "linux", "macos", or "windows".

Available Lua APIs

The praxis global provides:

  • Filesystem: path_exists, path_join, path_is_dir, path_parent, read_file, read_dir, write_file, remove_dir, walk_files, glob_files, count_file_lines, extract_user_home
  • Commands: command_run, command_run_handle, command_abort_handle
  • ACP: acp_start, acp_create_session, acp_prompt, acp_cancel, acp_is_alive, acp_close
  • Environment: os_name, user_homes, env_get, expand_path
  • Process: find_executables, kill_processes_by_name
  • CDP: cdp_spawn_and_connect, cdp_connect, cdp_evaluate, cdp_click, cdp_type_text, cdp_press_key, cdp_wait_for_element, cdp_find_elements, cdp_close, cdp_process_id
  • UIA: uia_root, uia_find_window, uia_find, uia_find_bfs, uia_find_all, uia_name, uia_classname, uia_invoke, uia_expand, uia_focus, uia_send_keys, uia_window_close, uia_toggle, uia_toggle_state, uia_bounding_rect, uia_set_foreground, uia_click_at, uia_release — see Windows UI Automation below
  • Utilities: json_decode, json_encode, toml_decode, uuid_v4, now_unix, sleep_ms, sha256_hex, hex_decode, sqlite_query, format_unix_timestamp, is_cancelled, register_cancel, unregister_cancel, semantic_discover_internal_tools, log_info, log_warn, log_debug

This list is not exhaustive — see node/src/agent_connectors/lua/runtime.rs for every registered function.

The helpers module (require("praxis.helpers")) provides find_executable, expand_path, starts_with, ends_with, dedup, parse_json, parse_toml, user_homes_with_dir, for_each_user_home_coalesce, run_standard_recon, collect_configs, extract_mcp_servers, make_verify_version_flag, has_any_env_var, subprocess_session, discover_command_skills, discover_skill_md_skills, dedup_skills, auth_via_env_or_files, resolve_working_dir, discover_jsonl_sessions, find_latest_session_file, add_config_if_exists, find_project_directories, parse_frontmatter_field, first_meaningful_line, strip_extension, and parser helpers such as parse_mcp_from_json, parse_mcp_from_json_flexible, and parse_mcp_from_toml. This list is also not exhaustive — see node/src/agent_connectors/lua/lib/helpers.lua for the complete set.

The devtools module (require("praxis.devtools")) provides connect, transact, and close for browser-based agents using Chrome DevTools Protocol, plus a renderer_ops backend for Electron apps whose renderer DOM isn't reachable from the main-process CDP connection. See DevTools-Based Agents below.

Deploying

  • Embedded: Add the .lua file to agents/ and rebuild. It will be compiled into both node and service binaries.
  • Runtime: Upload via the praxis TUI's Settings → Agents tab. The script is stored in the service database and pushed to all connected nodes.

ACP Sessions (Streaming Agents)

For agents that support the Agent Client Protocol (ACP), sessions use a long-lived subprocess with JSON-RPC 2.0 over NDJSON stdio. Praxis uses the agent-client-protocol crate internally, providing typed ClientSideConnection communication with Client trait callbacks for real-time streaming updates (text chunks, tool calls, plans, permission requests).

ACP Lua API

FunctionArgumentsReturnsDescription
praxis.acp_startspec tablehandle (string)Spawn an ACP subprocess and perform the initialize handshake
praxis.acp_create_sessionhandle, cwdsession_id (string)Create an ACP session with a working directory
praxis.acp_prompthandle, prompt, yolo, interactiveresponse (string)Send a prompt and wait for the streamed response. yolo auto-approves permission requests; interactive forwards them to the user
praxis.acp_closehandleClose the ACP session and terminate the subprocess

The acp_start spec table:

FieldTypeDescription
programstringPath to the agent executable
argstableCommand-line arguments (e.g. { "acp" } or { "--acp" })
cwdstringWorking directory for the subprocess

Example

create_session = function(ctx)
  local acp_handle = praxis.acp_start({
    program = ctx.process_path,
    args = { "--acp" },
    cwd = ctx.working_dir or "",
  })

  local session_id = praxis.acp_create_session(acp_handle, ctx.working_dir or "")

  return {
    acp_handle = acp_handle,
    acp_session_id = session_id,
    yolo_mode = ctx.yolo_mode == true,
    interactive = ctx.interactive == true,
  }
end,

session_transact = function(_ctx, state, prompt)
  local response = praxis.acp_prompt(
    state.acp_handle, prompt,
    state.yolo_mode or false,
    state.interactive or false
  )
  return { response = response, state = state }
end,

session_close = function(_ctx, state)
  if state.acp_handle then
    praxis.acp_close(state.acp_handle)
  end
end,

During acp_prompt, streaming updates (text, tool calls, tool results) are automatically forwarded to the client (the praxis TUI) in real time. The function blocks until the full response is assembled and returns the final text.


DevTools-Based Agents (Browser Automation)

For agents that run in a browser or WebView (e.g. M365 Copilot), Praxis provides a CDP (Chrome DevTools Protocol) stack. The architecture has three layers, with a choice of DOM backend at the middle layer:

your_agent.lua                    ← Agent-specific: CSS selectors, response parsing
    ↓ uses
require("praxis.devtools")        ← Generic transact loop, connect/close lifecycle
    ↓ uses one of two DOM backends
cdp_ops (direct) / renderer_ops (Electron proxy)
    ↓ uses
praxis.cdp_*                      ← Native Rust: CDP connection, JS eval, DOM ops

The devtools Module

require("praxis.devtools") provides three functions:

FunctionDescription
devtools.connect(config)Spawn a process with a debug port, connect via CDP, return handle, desktop — the CDP handle string and a hidden-desktop handle (nil if none was used)
devtools.transact(handle, adapter, prompt)Send a prompt and poll for response using the adapter's selectors
devtools.close(handle)Close the CDP connection and terminate the process tree

The connect config table:

FieldTypeDescription
process_pathstringPath to the executable
debug_port_env_varstringEnvironment variable for the debug port argument
debug_port_formatstringFormat string, e.g. "--remote-debugging-port={}"
base_portnumberBase port number (random offset added)
port_rangenumberRange for random port selection (default 778)
kill_existingboolKill existing processes first (default true)
use_hidden_desktopboolSpawn on hidden desktop on Windows (default true). In debug builds, PRAXIS_NOT_HIDDEN defaults to 1 (visible); in release builds it defaults to 0 (hidden).

DOM Operation Backends

devtools.transact(handle, adapter, prompt, ops) takes an optional 4th argument selecting which backend performs DOM operations. It defaults to devtools.cdp_ops(handle).

  • devtools.cdp_ops(handle) (default) — direct chromiumoxide Page calls. Use this when the CDP connection can already see the page DOM: WebView2 apps and direct Chrome/Chromium connections.
  • devtools.renderer_ops(handle) — tunnels DOM operations through a CDP proxy injected into an Electron main process connection. Use this when the debugger is only attached to the main process (e.g. port 9229) and the renderer's DOM isn't reachable from that connection directly. Call devtools.setup_electron_proxy(handle, url_match) once, after connecting and before the first transact, to attach webContents.debugger to the renderer webContents matching url_match and install a globalThis.cdp() bridge. Then either pass devtools.renderer_ops(handle) as the 4th argument to devtools.transact, or use the devtools.electron_transact(handle, adapter, prompt) convenience wrapper, which does both.

renderer_ops is built from lower-level functions that are rarely called directly: renderer_evaluate, renderer_wait_for_element, renderer_find_elements, renderer_click, renderer_type_text, renderer_press_key.

The Claude Desktop connector (agents/claudedesktop.lua) is the worked example: it uses UI Automation (see Windows UI Automation below) to enable Electron's main-process debugger, connects CDP to that port, then calls setup_electron_proxy and drives the UI with electron_transact.

The Adapter Table

The transact function takes an adapter table that defines how to interact with the specific agent's UI:

local my_adapter = {
  -- CSS selector for the text input element (required)
  input_selector = '#chat-input',

  -- CSS selector for response message elements (required)
  message_selector = 'div.response-message',

  -- Check response state by running JS in the page (required)
  -- Returns: { response = string|nil, is_generating = bool, has_new_messages = bool }
  check_response_state = function(handle, initial_count)
    local result = praxis.cdp_evaluate(handle, [[
      (function() {
        var messages = document.querySelectorAll('div.response-message');
        var text = '';
        if (messages.length > 0) {
          text = messages[messages.length - 1].innerText.trim();
        }
        var loading = document.querySelector('.loading-indicator');
        return {
          responseText: text,
          messageCount: messages.length,
          isGenerating: loading !== null
        };
      })()
    ]])

    local count = (result and result.messageCount) or 0
    local generating = (result and result.isGenerating) or false
    local text = (result and result.responseText) or ""

    local response = nil
    if count > initial_count and not generating and #text > 0 then
      response = text
    end

    return {
      response = response,
      is_generating = generating,
      has_new_messages = count > initial_count,
    }
  end,

  -- Optional: wait for submit button to be enabled before pressing Enter
  wait_for_submit_ready = function(handle)
    praxis.cdp_wait_for_element(handle, 'button.send:not([disabled])', 50, 100)
  end,
}

Full Example

Here is an M365-style DevTools-based agent template:

local helpers = require("praxis.helpers")
local devtools = require("praxis.devtools")

local AGENT_NAME = "My DevTools Agent"
local AGENT_SHORT_NAME = "mydevtools"

local PROCESS_NAME = "MyAgent.exe"
local INPUT_SELECTOR = '#chat-input'
local MESSAGE_SELECTOR = 'div.assistant-message'
local SEND_BUTTON_SELECTOR = 'button[aria-label=\"Send\"]:not([aria-disabled=\"true\"])'
local STOP_BUTTON_SELECTOR = 'button[aria-label=\"Stop generating\"]'

local my_adapter = {
  input_selector = INPUT_SELECTOR,
  message_selector = MESSAGE_SELECTOR,

  check_response_state = function(handle, initial_count)
    local js = "(function() {"
      .. "var msgs = document.querySelectorAll('" .. MESSAGE_SELECTOR .. "');"
      .. "var text = '';"
      .. "if (msgs.length > 0) {"
      .. "  var last = msgs[msgs.length - 1];"
      .. "  text = (last.innerText || last.textContent || '').trim();"
      .. "}"
      .. "var stopBtn = document.querySelector('" .. STOP_BUTTON_SELECTOR .. "');"
      .. "return { responseText: text, messageCount: msgs.length, isGenerating: stopBtn !== null };"
      .. "})()"
    local result = praxis.cdp_evaluate(handle, js)

    local message_count = (result and result.messageCount) or 0
    local is_generating = (result and result.isGenerating) or false
    local response_text = (result and result.responseText) or ""
    local has_new_messages = message_count > initial_count

    local response = nil
    if has_new_messages and not is_generating and #response_text > 0 then
      response = response_text
    end

    return {
      response = response,
      is_generating = is_generating,
      has_new_messages = has_new_messages,
    }
  end,

  wait_for_submit_ready = function(handle)
    praxis.cdp_wait_for_element(handle, SEND_BUTTON_SELECTOR, 100, 100)
  end,
}

local function post_initialize(handle, _working_dir)
  -- Wait for the chat UI to be ready.
  praxis.cdp_wait_for_element(handle, INPUT_SELECTOR, 30, 300)

  -- Optional: click mode toggle, open fresh chat, dismiss banners, etc.
  -- pcall(praxis.cdp_click, handle, 'button[data-testid=\"new-chat\"]')
end

local function run_create_session(ctx)
  praxis.kill_processes_by_name(PROCESS_NAME)
  praxis.sleep_ms(500)

  local cdp_handle, desktop_handle = devtools.connect({
    process_path = ctx.process_path,
    debug_port_env_var = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS",
    debug_port_format = "--remote-debugging-port={}",
    base_port = 9222,
    port_range = 778,
  })

  post_initialize(cdp_handle, ctx.working_dir)

  return {
    handle = cdp_handle,
    cdp_handle = cdp_handle,
    desktop_handle = desktop_handle,
    working_dir = ctx.working_dir,
    process_id = praxis.cdp_process_id(cdp_handle),
  }
end

local function run_session_transact(state, prompt)
  local response = devtools.transact(state.cdp_handle, my_adapter, prompt)
  return { response = response, state = state }
end

local function run_session_close(state)
  if state and state.cdp_handle then
    devtools.close(state.cdp_handle)
  end
  if state and state.desktop_handle then
    praxis.release_desktop(state.desktop_handle)
  end
end

local function do_recon(ctx)
  if praxis.os_name() ~= "windows" then
    return nil
  end

  local internal_tools = {}
  if ctx.is_semantic == true then
    internal_tools = helpers.discover_internal_tools(
      { process_path = ctx.process_path, working_dir = nil },
      { create = run_create_session, transact = run_session_transact, close = run_session_close }
    )
  end

  return {
    config = { items = {}, project_paths = {} },
    tools = { mcp_servers = {}, skills = {}, internal_tools = internal_tools },
    sessions = { items = {} },
  }
end

local function do_fingerprint()
  if praxis.os_name() ~= "windows" then
    return nil
  end
  local paths = praxis.find_executables(PROCESS_NAME) or {}
  if #paths > 0 then
    return paths[1]
  end
  return nil
end

return {
  name = AGENT_NAME,
  short_name = AGENT_SHORT_NAME,

  fingerprint = function(_ctx)
    local path = do_fingerprint()
    return { available = path ~= nil, process_path = path }
  end,

  recon = function(ctx)
    return do_recon(ctx)
  end,

  create_session = function(ctx)
    return run_create_session(ctx)
  end,

  session_transact = function(_ctx, state, prompt)
    return run_session_transact(state, prompt)
  end,

  session_close = function(_ctx, state)
    run_session_close(state)
  end,
}

Session State Keys

For CDP sessions to support abort and cleanup, the session state returned by create_session should include:

  • handle — used by the Rust session layer for command abort lookup
  • cdp_handle — the CDP connection handle string (cleaned up by Rust on drop)
  • desktop_handle — the hidden-desktop handle returned alongside the CDP handle by devtools.connect (nil if use_hidden_desktop was off or no hidden desktop was created). Since use_hidden_desktop defaults to true on Windows, this is normally set — release it in session_close via praxis.release_desktop(state.desktop_handle), or the hidden desktop leaks
  • process_id — the spawned process PID (killed by Rust on abort or drop)

CDP API Reference

Low-level functions available on the praxis global:

FunctionArgumentsReturnsDescription
cdp_spawn_and_connectconfig tabletable with handle and desktop fieldsSpawn process, connect via CDP. desktop is a hidden-desktop handle (nil if none was used) — see DOM Operation Backends and Session State Keys above
cdp_connectport (number)handle stringConnect to existing DevTools endpoint
cdp_evaluatehandle, js (string)valueExecute JavaScript, return result
cdp_find_elementshandle, selectorcount (number)Count matching DOM elements
cdp_clickhandle, selectorClick an element
cdp_type_texthandle, textInsert text via CDP InsertText (handles emojis)
cdp_press_keyhandle, selector, keyPress a key on an element
cdp_wait_for_elementhandle, selector, retries, delay_msboolPoll for element existence
cdp_closehandleClose connection, terminate process
cdp_process_idhandlenumber or nilGet PID of spawned process

Windows UI Automation

Some Windows agents need to drive native window chrome — menus, dialogs, toggle switches — rather than, or in addition to, a browser/WebView DOM. Praxis provides a UI Automation (UIA) stack for this, structured the same way as the DevTools stack:

your_agent.lua                     ← Agent-specific: element names, menu paths
    ↓ uses
require("praxis.uiautomation")     ← Higher-level find/invoke/toggle helpers
    ↓ uses
praxis.uia_*                       ← Native Rust: UI Automation tree walking, patterns

The Claude Desktop connector (agents/claudedesktop.lua) is the worked example. It uses UIA to open Menu → Developer → Enable Main Process Debugger and dismiss the "Inspector" popup dialogs that follow, before it ever connects CDP:

local uia = require("praxis.uiautomation")

local debugger_item = uia.open_app_menu(
  window_id,
  "Menu",
  { "Developer", "Enable Main Process Debugger" }
)

if uia.toggle_state(debugger_item) == "off" then
  uia.toggle(debugger_item)
end
uia.release(debugger_item)

UIA API Reference

Low-level functions available on the praxis global, wrapped by require("praxis.uiautomation"):

FunctionDescription
uia_rootGet the desktop root element
uia_find_windowFind a top-level window by name and/or PID
uia_findFind the first child/descendant matching criteria (name, control type, class name, or scope)
uia_find_bfsBreadth-first find — avoids the hang uia_find's Descendants scope causes on large Electron UIA trees
uia_find_allFind all matching children/descendants
uia_nameGet an element's name
uia_classnameGet an element's class name
uia_invokeInvoke an element via the UIA InvokePattern (click/activate)
uia_expandExpand an element via the UIA ExpandCollapsePattern (e.g. open a menu)
uia_focusSet keyboard focus to an element
uia_send_keysSend keystrokes to an element (focuses it first)
uia_window_closeClose a window via the UIA WindowPattern
uia_toggleToggle a checkbox/toggle element via the UIA TogglePattern
uia_toggle_stateGet an element's toggle state: "on", "off", or "indeterminate"
uia_bounding_rectGet an element's bounding rectangle ({ x, y, width, height })
uia_set_foregroundBring a window to the foreground
uia_click_atClick at screen coordinates
uia_releaseRelease an element handle to free memory

require("praxis.uiautomation") also provides higher-level helpers built on these — find_window/wait_for_window, wait_for, click_element, release_all, dismiss_dialogs, and open_app_menu. See node/src/agent_connectors/lua/lib/uiautomation.lua for the full wrapper API.


Rust Connector (for native/OS-level agents)

Use this approach only when Lua cannot access the required OS capabilities. Native connectors implement the Agent, AgentSession, and optional AgentRecon Rust traits directly. Lua scripts do not implement these traits themselves; the shared LuaAgent and LuaAgentSession adapters translate their callbacks into the same runtime interfaces.

The abbreviated trait shapes are:

#![allow(unused)]
fn main() {
#[async_trait]
trait Agent {
    fn name(&self) -> &str;
    fn short_name(&self) -> &str;
    fn as_recon(&self) -> Option<&dyn AgentRecon>;
    async fn do_fingerprint(&self) -> bool;
    fn version(&self) -> Option<String>;
    fn create_session_with_id(
        &self,
        context: &SessionContext,
        session_id: Uuid,
    ) -> Option<Arc<dyn AgentSession>>;
    fn drop_session(&self, session_id: Uuid);
}

trait AgentSession {
    fn transact(&self, prompt: &str) -> Result<String>;
    fn close(&self);
}

#[async_trait]
trait AgentRecon {
    async fn perform_recon(&self, is_semantic: bool) -> Option<ReconResult>;
}
}

Step 1: Create the Directory Structure

Create a new directory under node/src/agent_connectors/:

node/src/agent_connectors/
├── exampleai/
│   ├── mod.rs          # Main agent implementation
│   ├── fingerprint.rs  # Fingerprinting logic
│   └── session.rs      # Session management
├── factory.rs
├── mod.rs
└── traits.rs

Step 2: Implement the Agent Trait

In mod.rs:

#![allow(unused)]
fn main() {
mod fingerprint;
mod intercept;
mod recon;
mod session;

pub use session::ExampleAISession;

use crate::agent_connectors::traits::{Agent, AgentRecon, AgentSession};
use async_trait::async_trait;
use common::SessionContext;
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

const AGENT_NAME: &str = "ExampleAI";
const AGENT_SHORTNAME: &str = "exampleai";

pub struct ExampleAIAgent {
    pub(crate) process_path: OnceCell<String>,
    //
    // Per-session state keyed by the ACP session_id handed in by
    // the node's ACP server. Nothing is shared between sessions.
    //
    sessions: Mutex<HashMap<Uuid, Arc<dyn AgentSession>>>,
}

impl ExampleAIAgent {
    pub fn new() -> Self {
        Self {
            process_path: OnceCell::new(),
            sessions: Mutex::new(HashMap::new()),
        }
    }
}

#[async_trait]
impl Agent for ExampleAIAgent {
    fn name(&self) -> &str {
        AGENT_NAME
    }

    fn short_name(&self) -> &str {
        AGENT_SHORTNAME
    }

    fn as_recon(&self) -> Option<&dyn AgentRecon> {
        Some(self)  // Return None if no recon support
    }

    async fn do_fingerprint(&self) -> bool {
        self.do_fingerprint_impl().await
    }

    fn create_session_with_id(
        &self,
        context: &SessionContext,
        session_id: Uuid,
    ) -> Option<Arc<dyn AgentSession>> {
        match ExampleAISession::new(self.process_path.get().cloned(), context, session_id) {
            Ok(session) => {
                let session_arc: Arc<dyn AgentSession> = Arc::new(session);
                self.sessions.lock().unwrap().insert(session_id, Arc::clone(&session_arc));
                Some(session_arc)
            }
            Err(e) => {
                common::log_error!("{}: Failed to create session: {}", AGENT_NAME, e);
                None
            }
        }
    }

    fn drop_session(&self, session_id: Uuid) {
        if let Some(session) = self.sessions.lock().unwrap().remove(&session_id) {
            session.close();
        }
    }
}
}

The Agent trait has two session-related hooks:

  • create_session_with_id(ctx, session_id) — called once per session/new ACP request. The node's ACP server chooses the session_id; the agent must build a session that does not share mutable state with any other session.
  • drop_session(session_id) — called on session/close (and on node reset). Release per-session resources keyed by that id.

Step 3: Implement Fingerprinting

In fingerprint.rs:

#![allow(unused)]
fn main() {
use super::ExampleAIAgent;
use std::path::PathBuf;

impl ExampleAIAgent {
    pub(crate) async fn do_fingerprint_impl(&self) -> bool {
        // Check for config file
        if let Some(config_path) = find_config_file() {
            common::log_info!("ExampleAI: Found config at {:?}", config_path);

            // Optionally find and cache the binary path
            if let Some(binary_path) = find_binary() {
                let _ = self.process_path.set(binary_path);
            }

            return true;
        }

        // Check for running process
        if is_process_running("exampleai") {
            return true;
        }

        false
    }
}

fn find_config_file() -> Option<PathBuf> {
    let home = dirs::home_dir()?;

    // Check common config locations
    let paths = [
        home.join(".exampleai/config.json"),
        home.join(".config/exampleai/config.json"),
    ];

    paths.into_iter().find(|p| p.exists())
}

fn find_binary() -> Option<String> {
    which::which("exampleai").ok().map(|p| p.to_string_lossy().to_string())
}

fn is_process_running(name: &str) -> bool {
    // Platform-specific process detection
    // ...
    false
}
}

Step 4: Configure Interception

Traffic interception is no longer declared on the connector itself. Domains and URL filters live as intercept targets in a TOML virtual file on the service, and the parsed list is pushed to nodes at runtime. To enable capture for a new connector:

  1. Open Settings → Intercept in the praxis TUI.

  2. Select Edit virtual file in $EDITOR and add a new section keyed by the connector's agent_short_name. For example:

    [exampleai]
    domains = ["api.example.ai"]
    url_pattern = "v1/chat"  # optional
    
  3. Save the file and exit the editor. The service parses the new contents, persists them, and broadcasts the updated list to all connected nodes immediately. Parse errors are reported in the settings status bar and the stored file is left untouched.

Built-in connectors ship in this file by default; use the Reset to built-in defaults action to discard local edits and start over. To disable a target without deleting it, comment out the entire section with #.

Step 5: Implement Reconnaissance

In recon.rs:

#![allow(unused)]
fn main() {
use super::ExampleAIAgent;
use crate::agent_connectors::traits::AgentRecon;
use async_trait::async_trait;
use common::ReconResult;

#[async_trait]
impl AgentRecon for ExampleAIAgent {
    async fn perform_recon(&self, is_semantic: bool) -> Option<ReconResult> {
        let mut result = ReconResult::default();

        // Discover configuration files
        if let Some(item) = discover_config() {
            result.config.items.push(item);
        }

        // Discover tools/plugins (MCP servers + skills)
        result.tools = discover_tools();

        // Discover session history
        result.sessions.items = discover_sessions();

        // Optional: populate internal_tools via semantic enrichment
        if is_semantic {
            // result.tools.internal_tools = ...;
        }

        Some(result)
    }
}

fn discover_config() -> Option<common::ConfigItem> {
    // Parse config files, return structured data
    None
}

fn discover_tools() -> common::ReconTools {
    // Find plugins, extensions, MCP servers
    common::ReconTools::default()
}

fn discover_sessions() -> Vec<common::SessionItem> {
    // Find session history files
    Vec::new()
}
}

Step 6: Implement Session Management

In session.rs:

#![allow(unused)]
fn main() {
use crate::agent_connectors::traits::AgentSession;
use anyhow::Result;
use common::SessionContext;
use uuid::Uuid;

pub struct ExampleAISession {
    session_id: Uuid,
    process_path: Option<String>,
    working_dir: Option<String>,
    pty: Option<PtyHandle>,  // Your PTY abstraction
}

impl ExampleAISession {
    pub fn new(
        process_path: Option<String>,
        context: &SessionContext,
        session_id: Uuid,
    ) -> Result<Self> {
        // Spawn the agent process
        let mut cmd = std::process::Command::new(
            process_path.as_deref().unwrap_or("exampleai")
        );

        if let Some(ref dir) = context.working_dir {
            cmd.current_dir(dir);
        }

        if context.yolo_mode {
            cmd.arg("--auto-approve");
        }

        // Create PTY and spawn
        let pty = create_pty_session(cmd)?;

        Ok(Self {
            session_id,
            process_path,
            working_dir: context.working_dir.clone(),
            pty: Some(pty),
        })
    }
}

impl AgentSession for ExampleAISession {
    fn transact(&self, prompt: &str) -> Result<String> {
        // Send prompt to PTY stdin
        // Wait for and parse response
        // Return assistant's message

        if let Some(ref pty) = self.pty {
            pty.write(prompt)?;
            let response = pty.read_until_complete()?;
            Ok(parse_response(&response))
        } else {
            Err(anyhow::anyhow!("No PTY available"))
        }
    }

    fn close(&self) {
        if let Some(ref pty) = self.pty {
            pty.close();
        }
    }
}
}

AgentSession only requires transact and close. Override these when needed:

  • acp_handle(&self) -> Option<String> — return a non-empty handle for streaming sessions so the update/permission channels can be registered against it before transact runs. Non-streaming sessions should leave the default (None).
  • abort_transaction(&self) -> bool — kill the underlying process on abort; return whether one was actually killed.
  • set_cancel_flag(&self, flag: Arc<AtomicBool>) — adopt the shared cancellation flag the handler hands in before calling transact, so session/cancel and in-loop polling share one flag.
  • transact_with_context(&self, prompt: &str, ctx: SessionTransactContext) -> Result<String> — the default implementation wires set_cancel_flag and registers acp_handle's update/permission channels, then calls transact. Override only if a session needs different wiring.

Step 7: Register in Factory

Lua connectors need no factory changes at all. Dropping the .lua file into agents/ (see Deploying above) is enough: AgentRegistry::rebuild() calls lua::load_embedded_agents() to load every embedded script and register it directly, without AgentFactory ever being involved. Claude Code, Gemini, M365 Copilot, and every other shipped connector except Praxis itself work this way.

AgentFactory::create_all_agents() only needs to know about connectors that are genuinely native Rust — currently just PraxisAgent (constructed conditionally from config) and DummyAgent (native, but only used in tests). Register a new native connector like ExampleAIAgent the same way:

Update node/src/agent_connectors/factory.rs:

#![allow(unused)]
fn main() {
use super::exampleai::ExampleAIAgent;  // Add import

impl AgentFactory {
    pub fn create_all_agents(&self) -> Vec<Arc<dyn Agent>> {
        let mut agents: Vec<Arc<dyn Agent>> = Vec::new();
        let config = self.config();

        if let Some(praxis_config) = config.praxis_agent_config {
            agents.push(Arc::new(PraxisAgent::new(praxis_config)));
        }

        // Add your new native connector.
        agents.push(Arc::new(ExampleAIAgent::new()));

        agents
    }
}
}

Update node/src/agent_connectors/mod.rs:

#![allow(unused)]
fn main() {
pub mod exampleai;  // Add this line
}

Step 8: Test

  1. Build the node: cargo build -p praxis_node
  2. Run with the target agent installed
  3. Check fingerprinting works
  4. Test reconnaissance
  5. Test session creation and prompts
  6. Test interception (if implemented)

Tips

Fingerprinting

  • Be defensive-check multiple locations
  • Handle missing files gracefully
  • Log what you find for debugging

Sessions

  • Handle terminal control sequences properly
  • Parse output carefully-agents have different formats
  • Implement proper cleanup on close

Recon

  • Start with file-based discovery (the standard pipeline in helpers.run_standard_recon)
  • Use the shared parsers (parse_mcp_from_json, parse_mcp_from_toml) when possible
  • Cache results where appropriate

Testing

  • Test without the agent installed (should not crash)
  • Test with partial configuration
  • Test session edge cases (timeouts, errors)